From d2497aec5eafe5c6c1f9cac0f4d12bb6e6eb0414 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:18:53 -0700 Subject: [PATCH 001/194] Add APS render fix and TSJS resilience design spec Synthesizes three audits of the full rc/july merged state (APS end-to-end trace, TSJS architecture audit, GPT integration map) into a phased design: client-to-server disposition telemetry first, APS admission/identity/render fixes second, then the kernel/adapters/ services restructuring of TSJS with a single window.tsjs namespace, performance budgets, and restoration of the render attribution lost in the #922 merge. --- ...s-render-fix-and-tsjs-resilience-design.md | 591 ++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md new file mode 100644 index 000000000..d8550b05a --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -0,0 +1,591 @@ +# APS Render Fix and TSJS Resilience Architecture — Design + +- **Status:** draft for review +- **Date:** 2026-08-04 +- **Baseline:** `rc/july` @ `541298695` — the full merged state (everything merged + from `main` plus every rc-only merge), not just the delta pending against + `main`. +- **Inputs:** three code audits performed against this baseline (APS end-to-end + trace, TSJS architecture audit, GPT integration map); open issues #926, #941, + #944, #962, #964, #977, #983, #989, #993; open PR #997. + +--- + +## 1. Problem statement + +APS (Amazon Publisher Services) demand is fully integrated server-side — the +edge server runs the APS OpenRTB auction, wins bids, and ships a typed renderer +descriptor to the page — yet APS creatives still do not appear for real users. +Every previous fix (the `bid.meta` carrier so Prebid does not strip the +descriptor, the decoupled prebid shim, the `hb_adid` fallback to the OpenRTB bid +id) addressed a real defect, and APS still does not render. That pattern — serial +single-cause fixes that each survive review and still do not produce ads — is +itself the finding: the APS pipeline has **multiple independent failure points, +most of which fail silently**, and the client library has **no way to tell the +server (or the operator) which one fired**. + +At the same time, the TSJS client library has grown organically to 56 files / +~11,900 lines with two ~1,700-line monoliths, duplicated logic maintained by +hand in two languages, inverted layering, and roughly one hundred `catch` blocks +that discard failures. The APS outage and the library's shape are the same +problem seen from two sides: a delivery pipeline whose failure modes are +invisible and whose components cannot be reasoned about independently. + +This design covers both: (a) the specific fixes that make APS render, and (b) +the target architecture that makes TSJS a clean, resilient library so the next +integration does not reproduce this failure class. + +### Non-goals + +- No change to the APS OpenRTB endpoint contract or Amazon-side configuration. +- No rewrite of Prebid.js integration strategy (the decoupled shim stays). +- No visual/behavioral change for publishers whose pages work today. + +--- + +## 2. Why APS still does not render — the evidence + +The audit traced all four delivery flows: (a) SSAT server-side ad template via +`window.tsjs.bids`, (b) GAM + client-side `trustedServer` Prebid adapter, (c) +SPA `/_ts/page-bids` re-auction, (d) direct `/auction` via `tsjs.requestAds`. +Only flow (d) — the demo path nobody runs in production — can render an APS +descriptor without GAM's cooperation. + +The failure points, ranked by likelihood and blast radius: + +### 2.1 Admission: APS bids are eliminated before they can win + +| # | Failure | Where | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | +| A1 | **A configured `[auction].mediator` discards every direct-provider bid.** Winners come exclusively from the mediator response; APS bids (with their renderers) are used only as mediator input and reporting. APS shows `status: success, bid_count: N` yet never wins a slot. | `orchestrator.rs:412-431` | +| A2 | **`allow_script_creatives` defaults to `false`**, dropping every `tagtype: "script"` APS bid — a large share of TAM demand. The drop is counted but invisible (see A4). | `aps.rs:141-143`, `:773-778` | +| A3 | **Strict per-bid gates**: exact `w`×`h` match against configured formats (a 300×600 answer on a `[[300,250],[728,90]]` slot dies), required `ext.creativeurl`, and any top-level `contextual` key rejects the entire response. | `aps.rs:657-668`, `:745-778`, `:838-846` | +| A4 | **Drop reasons never reach an operator on the production paths.** `drop_reasons` counters surface only in `/auction` `ext.orchestrator`; the SSAT and page-bids paths discard them, server logs do not carry them, and the `ts-debug` comment allowlist excludes them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | + +### 2.2 Identity: the `hb_adid` contract with GAM is unproven + +| # | Failure | Where | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| B1 | **GAM key-value values are capped at 40 characters.** The new fallback emits the raw APS OpenRTB bid `id` (a long opaque string) as `hb_adid`. If GAM truncates or rejects it, `%%PATTERN:hb_adid%%` comes back different, the bridge's equality check fails, and it bails **with no log**. | `publisher.rs:3366-3372`, `gpt/index.ts:1613` | +| B2 | **Two id universes for the same bid.** SSAT keys the bridge on the APS bid id; the client-side Prebid adapter keys on Prebid's generated `adId`. A page running both paths registers the same slot under different ids. | `publisher.rs:3366`, `prebid/index.ts:982` | + +### 2.3 Render: the client has one narrow happy path and no fallback + +| # | Failure | Where | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| C1 | **If GAM never serves the Prebid Universal Creative, nothing renders and nothing is recorded.** The renderer descriptor sits unused in `window.tsjs.bids`; `renderApsCreative` is reachable only from the unused flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` | +| C2 | **The `/integrations/aps/renderer` route registers only when `[integrations.aps]` is enabled on that origin.** A 404/401 there means the sandboxed iframe waits 10 s and dies silently. | `aps.rs:1188-1245`, `aps/render.ts:404` | +| C3 | **SafeFrame breaks slot attribution.** The bridge resolves a message source by walking top-document iframes under the slot div; a nested SafeFrame creative window is invisible to that walk, so the bridge bails silently. | `gpt/index.ts:157-183`, `:1599-1600` | +| C4 | **Three hand-maintained copies of the descriptor schema** (Rust struct, TS validator, inline renderer-document validator) with exact-key rejection: any server-side field addition instantly blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` | +| C5 | **A dead duplicate renderer branch in the bridge** contains the debug log and dedup logic people would look for while debugging; it can never execute. | `gpt/index.ts:1643-1674` | +| C6 | **The renderer CSP may kill creatives after success is reported** (`object-src`, workers, `blob:`/`data:` frames are blocked; `renderer-ready` fires before the creative actually paints). | `aps.rs:49` | +| C7 | **The renderer branches record nothing**: no `recordRender`, no win/billing beacons, no `stampCreativeTrace` — an APS win looks "never rendered" in every trace whether or not it painted. | `gpt/index.ts:1558-1632` | + +### 2.4 Observability: the common factor + +There is **zero client→server reporting**. Server telemetry marks `is_win=1` at +auction time and goes quiet; a bid that never painted is byte-identical to one +that painted perfectly. Client-side evidence (`window.tsjs.renders`, console +warnings, `data-ts-*` attributes) dies with the tab. This is why "APS still does +not work" has taken weeks instead of a dashboard row saying +`render_fail{renderer_endpoint_404}`. + +--- + +## 3. The GPT reality this design must respect + +The GPT integration is a **bootstrap-first hybrid**: the server injects a +495-line ES5 `gpt_bootstrap.js` inline in `` before the TSJS bundle, and +the two coordinate through shared monkeypatch sentinels, with document order +deciding the winner. The audit's key facts: + +1. **The bundle's handoff and initial-load code is dead in production.** The + bootstrap installs its wrappers first and sets the same sentinels the bundle + checks; ~200 lines of the TypeScript the test suite exercises most heavily + never run on a real page. A fix landed only in `gpt/index.ts` has no + production effect. +2. **The slot handoff can alias a publisher's new div to a GPT slot bound to a + dead element** — and the orphan-recovery watcher built to repair exactly that + was **lost in the #922 merge** (`0dc9b19a9` resolved `gpt/index.ts` to the rc + side). `updateRender` (one impression, one row) now has no production + caller, `__tsRenderGeneration` / `__tsRenderBid` are dead writes, and every + bridge-served impression double-counts in the trace. Open PR #997 appears to + be the reworked replacement; restoring this is a correctness prerequisite, + not a refactor. +3. **TS refreshes never pass `changeCorrelator: false`**, so every TS-driven + refresh starts a new GAM page-view correlator — silently changing roadblock, + competitive-exclusion, and frequency-capping behavior. +4. **`enableSingleRequest()` is called blind** after the publisher's own + `enableServices()` has almost always run (post-#945 deferral), so SRA intent + is asserted but not real — and on pages where TS wins the race, it forces + SRA onto publishers who chose otherwise. +5. Responsive resolution is a DOM-element-selection ladder (not GPT size + mapping); ambiguity silently skips the slot for the whole pass. +6. Three independent wrappers on `pubads().refresh` (bootstrap, bundle, prebid) + coordinate via window-global booleans that async wrappers observe already + reset. + +Any APS fix that adds more targeting keys, more refresh calls, or more +postMessage traffic has to land inside this reality, which is why the design +couples the APS fix to the library restructuring instead of adding a sixth +patch to the pile. + +--- + +## 4. Design overview + +Three workstreams, ordered by dependency: + +1. **See the failures** — client→server disposition telemetry plus surfacing + the server's existing drop counters. Without this, every subsequent fix is + another blind patch. +2. **Fix APS delivery** — admission, identity, render chain, schema, and + bridge fixes, each verifiable by the new telemetry and by contract tests. +3. **Restructure TSJS** — the kernel/adapters/services architecture that makes + the fixes durable and the next integration cheap. + +--- + +## 5. Workstream 1 — Observability first + +### 5.1 Client disposition beacon + +A new kernel module batches disposition events and posts them to a new +`POST /_ts/client-events` ingest route: + +``` +{ v: 1, page: {auctionId, navGen}, events: [ + { t: "bid_received", slot, bidder, source } + { t: "targeting_set", slot, hbAdid } + { t: "bridge_request", slot, adId, matched: bool } + { t: "render_attempt", slot, source: "renderer"|"adm"|"pbs-cache" } + { t: "render_ok", slot, source } + { t: "render_fail", slot, source, reason } // reason is a closed enum +] } +``` + +- Transport: `navigator.sendBeacon` with `fetch` keepalive fallback; batched + (flush on `visibilitychange`/`pagehide` and every 5 s); capped payload. +- Server side: a bounded, sampled log/telemetry row per event class, joining on + the auction id the server already logs. No KV writes, no PII, no cookies. +- The existing `recordRender` funnel becomes a producer for this beacon, so the + in-page trace overlay and the server see the same stream. + +`reason` enums are the contract: `renderer_endpoint_404`, +`renderer_ready_timeout`, `descriptor_invalid`, `bridge_id_mismatch`, +`gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, and so on. +Every silent `return` found by the audit gets a reason code. + +### 5.2 Surface the server's own drop counters + +- Log `drop_reasons` at `warn` when an APS response yields zero admitted bids. +- Add `drop_reasons` to auction telemetry rows and to the `ts-debug` comment + allowlist (SSAT and page-bids paths). +- Startup validation warning when `[integrations.aps]` is enabled while + `allow_script_creatives = false`: "script-type APS demand will be dropped." +- Startup validation warning when APS (or any direct provider) is configured + alongside a mediator, until Workstream 2 makes that combination meaningful. + +**Exit criterion:** an operator can answer "which of the failure points in +section 2 is firing on this page" from server logs alone, with one page load. + +--- + +## 6. Workstream 2 — APS delivery fixes + +### 6.1 Admission + +- **Mediated auctions must not discard direct-provider winners (A1).** New + winner-merge policy: after the mediator responds, direct-provider bids + compete per slot by decoded CPM against mediator bids under a configurable + strategy: `mediator_only` (today's behavior, explicit), `merge_highest_cpm` + (new default). The delivery report gains + `dropped_winner_reasons["mediator_superseded"]` so the loser is visible. +- **Dimension tolerance (A3).** Replace exact `w`×`h` equality with a + containment rule: an APS bid is admitted when its size fits within any + configured format for the slot (never larger on either axis); the served size + is reported in targeting. Exact match stays preferred when available. +- **Script creatives (A2).** Keep the secure default (`false`) but make the + consequence loud (5.2) and document the enablement path for TAM-heavy + publishers. The renderer sandbox already isolates script tag types; this is a + policy toggle, not new machinery. + +### 6.2 Render identity: one short token + +Introduce a server-generated **render token** — 12 chars, `[a-z0-9]`, unique per +(auction, slot) — emitted as `hb_adid` for every SSAT bid and used as the key in +every registry and bridge branch: + +- Well inside GAM's 40-char value limit and charset rules (B1). +- The bid map carries `{ hb_adid: token, bid_id, renderer, … }`; the bridge + matches on the token; billing/win URLs keep using the real bid id. +- The client-side Prebid adapter path keeps Prebid's generated `adId` (that + contract is Prebid's own), but registration for both paths lands in **one** + registry keyed by whichever token the path will observe (B2). +- Property test: every emitted `hb_adid` matches `^[a-z0-9-]{1,40}$`. + +### 6.3 Render source chain with a GAM-claim timeout + +A winning bid becomes an ordered list of render sources: +`renderer → inline adm → pbs-cache`. The render engine walks the chain, emitting +`render_attempt` / `render_ok` / `render_fail{reason}` per step. + +For flow (a)/(c), add the missing fallback (C1): when targeting was set for a +slot and **no bridge request arrives within N seconds of `slotRenderEnded` +(empty) or within M seconds of refresh**, and the config opts in +(`[auction].client_render_fallback = "renderer"`), render the descriptor +directly into the slot container via the existing `renderApsCreative` path. The +fallback is opt-in because it changes GAM reporting semantics; the beacon makes +the "GAM never asked" case visible either way. + +### 6.4 One descriptor schema + +The Rust `ApsRendererV1` struct becomes the single source of truth: + +- `build.rs` (or a checked-in generation step) exports JSON Schema from the + serde model; the TS types and validators in `aps/render.ts` and the inline + renderer-document validator are **generated** from it. +- Validation becomes versioned-envelope tolerant: known fields validated + strictly, unknown fields ignored, `version` gates behavior (C4). +- A conformance test round-trips a Rust-serialized descriptor through the TS + validator and the renderer-document validator in CI. + +### 6.5 Renderer endpoint availability + +- Register the `/integrations/aps/renderer` route whenever the server can emit + renderer bids (auction-level concern), not only when the APS integration is + enabled on the serving origin (C2). +- The renderer iframe failure path (10 s timeout, load error) emits + `render_fail{renderer_endpoint_404 | renderer_ready_timeout}` instead of + dying silently. +- CSP audit (C6): extend `APS_RENDERER_CSP` with the minimum additional sources + observed in real Amazon creative traffic (candidates: `frame-src data: blob:`, + `worker-src blob:`), each addition justified in a comment and covered by the + browser spec. + +### 6.6 Bridge hardening + +- Delete the dead duplicate renderer branch (C5) and move its dedup + + debug-log into the live branch. +- Renderer branches call the same `fireWinBillingBeacons` + + `recordGptBridgeRender` as the adm and cache branches (C7). +- Blanket source validation at the top of the bridge listener: parse and + ownership-check before any branch logic; new branches inherit protection. +- SafeFrame-aware attribution (C3): resolve the slot by the MessageChannel port + and the `hb_adid` token first (the token is already unique per slot), using + the DOM walk only as a fallback. + +### 6.7 Tests that pin the contract + +1. Browser spec for flow (a): real GPT + PUC handshake driven from + `window.tsjs.bids` with a renderer-only bid (the region the dead code hid). +2. Mediator + APS orchestration test asserting `merge_highest_cpm` admits the + APS winner and `mediator_only` reports the drop. +3. `build_bid_map` tests: renderer emission, token-form `hb_adid`, adm and + cache-coordinate suppression for renderer bids. +4. Cross-schema conformance (6.4). +5. Page-bids JSON carries `renderer`; SPA hook delivers it to the bridge. + +--- + +## 7. Workstream 3 — TSJS target architecture + +### 7.1 Layering + +``` +kernel/ boot, config, command queue, event bus, log, telemetry beacon +adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access +services/ slots (registry+handoff), auction client, render engine, consent +integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services) +``` + +Rules, enforced by an eslint boundary rule in CI (`import/no-restricted-paths`): + +- `kernel` imports nothing above it; `adapters` import kernel only; `services` + import kernel + adapters; `integrations` import kernel + services, **never + each other**. +- This dissolves today's inversions: `core/auction.ts` and `core/request.ts` + importing `integrations/aps/render`, `gpt` and `prebid` importing `aps`, and + `prebid` owning the GPT refresh wrapper. +- `aps/` gains a real module boundary (an `index.ts`), ending the triple + inlining that gives three bundles three private copies of the frame-tracking + WeakMaps (today two paths can each mount a live APS iframe on one container + without seeing each other's cancel bookkeeping). + +### 7.2 Adapters: explicit absence + +Every external global is wrapped once with a tri-state +(`present | pending | absent`), a queue for `pending`, and a resolution +timeout that emits telemetry on `absent`. No other file touches +`window.googletag` / `window.pbjs`. This converts today's silent hangs (GPT +stub whose `cmd` never drains, `adInit` bare-returning without googletag) into +recorded, reasoned outcomes. + +### 7.3 Slot registry service + +One registry owns all slot knowledge: publisher-defined vs TS-defined, +adoption, handoff claims, responsive element resolution, refresh generation, +targeting-key history — keyed by `WeakMap` plus a +div-id index. Expando properties on live GPT objects are eliminated. The GPT +integration feeds events in and executes registry decisions; the prebid refresh +handler consumes the same registry instead of re-deriving slot resolution. + +### 7.4 Global namespace policy: everything under `window.tsjs` + +Today the library sprawls across the window: ten-plus `window.__tsjs_*` / +`window.__ts*` flags, `globalThis.tscreative` / `tsCreativeConfig`, a +symbol-keyed dispatcher, and expando properties stamped onto foreign objects +(`__tsPushed` on GPT's command queue, `__tsSlotHandoffPatched` on wrapped +functions, `__tsRenderGeneration` / `__tsRenderBid` on live GPT slot objects, +sentinels on `pbjs`). The policy going forward: + +- **One owned global: `window.tsjs`**, split internally into `tsjs` (public, + versioned API) and `tsjs._internal` (coordination state, explicitly not a + contract). Server-injected boot flags (`__tsjs_gpt_enabled`, + `__tsjs_slim_prebid_url`, bundle manifests) become fields the boot script + sets via the same command-queue pattern (`window.tsjs = window.tsjs || +{cmd: []}`), so early inline scripts and the bundle share one namespace. +- **No expandos on objects we do not own.** Per-slot state + (`__tsRenderGeneration`, `__tsRenderBid`) moves into the slot registry's + `WeakMap`; wrap-idempotence sentinels on foreign + functions are replaced by a kernel-held `WeakSet` of wrapped targets. +- **Immediate cleanup, independent of the refactor:** `__tsRenderGeneration` + and `__tsRenderBid` are dead writes on this baseline — written at + `gpt/index.ts:1086-1091`, read by nothing (their consumer was lost in the + #922 merge). Delete the writes now; when Phase 2 restores attribution, the + captured bid/generation lives in `SlotRecord`. +- Third-party globals (`googletag`, `pbjs`, CMP APIs) are read only through + adapters (7.2); integration-owned config globals (`didomiConfig`, + `permutive.config`) are written only inside that integration's adapter + boundary. + +### 7.5 Messaging module + +All `postMessage` traffic goes through one module: versioned envelopes, message +name constants (today `'Prebid Request'` appears as a bare literal at six +sites, and the APS handshake exists in three hand-synced copies), source and — +where origins are non-opaque — origin validation, and one audit point. + +### 7.6 Lifecycle discipline + +- **`install()` entry points instead of import-time side effects.** The server + boot script calls `tsjs.install(['gpt', 'prebid', …])`; modules stop + self-executing at module bottom. This kills the double-injection class + (today: `beacon_guard` double-wraps `window.fetch` unrecoverably, a second + `creative` copy silently disarms `setConfig`, `didomi` can throw during + module evaluation and halt the concatenated bundle). +- One shared window-level install sentinel helper (the pattern + `gpt_diagnostics` already got right), applied to every integration. +- A `PageSession` object owns all per-page mutable state; SPA navigation + disposes and recreates it (fixing the leaked observers, listeners, and + unbounded maps the audit enumerated). +- Error policy: no empty `catch` — every catch either handles, logs with + context, or emits a disposition reason. The auction fetch gets a timeout + + `AbortController`, and `requestAds` surfaces failure to its caller. +- **Console logging is retained, not replaced.** The disposition beacon is + additive: every condition that surfaces an issue keeps (or gains) a + `log.warn` with enough context to debug from an open DevTools console, + because the console is the tool available on a publisher's page when no + server access exists. Concretely: existing warnings survive the refactor + verbatim or strengthened; failure paths currently logged at `debug` — which + is invisible at the default `warn` level (for example the creative + `dynamic_src_guard` and click-guard rejection paths) — are promoted to + `warn` when they indicate a delivery or security-relevant failure; and every + new `render_fail` / `absent`-dependency disposition emits a paired `warn` + carrying the same reason code, so console and beacon tell one story. + +### 7.7 The bootstrap problem + +`gpt_bootstrap.js` duplicates ~400 lines of the hardest logic (handoff, +initial-load detection, hydration deferral) in hand-written ES5, always wins +the sentinel race, and has one live divergence (its simpler `adInit` can run +first and permanently suppress the bundle's `slotRenderEnded` listener). + +Target: shrink the inline bootstrap to a **queue-and-flags stub only** (create +`googletag.cmd` interception points, record early publisher calls, expose +`__tsjs_gpt_enabled`), and move all behavior into the bundle, which replays the +recorded early calls on install. If a no-bundle fallback must keep rendering +ads (today's pinned behavior), that fallback is **generated from the same +TypeScript source** at build time, never hand-maintained. + +### 7.8 GPT correctness fixes carried with the restructure + +- Restore the #922 orphan-slot recovery and `updateRender` enrichment (verify + against open PR #997; land whichever is canonical) — fixes the dead-element + handoff alias (section 3.2) and trace double-counting. +- Pass `changeCorrelator: false` on TS-initiated refreshes; make correlator + behavior a documented, configurable decision. +- `enableSingleRequest()` only when GPT services are not already enabled; + otherwise adopt the publisher's mode and record it. +- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` instead + of only a console warning. + +### 7.9 Decomposition targets + +| Today | Target | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | +| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (moves onto slot registry), eids, diagnostics | +| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory the other six integrations already use | +| `core/trace.ts` (record model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | +| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split into public API vs internal coordination state | + +--- + +### 7.10 Performance + +Resilience must not cost speed; several parts of this design make the library +faster, and the rest are held to explicit budgets: + +**Where the design is a speedup:** + +- **Smaller synchronous bundle.** Consolidating `gpt/script_guard.ts` (634 + lines) onto the shared factory, un-inlining `aps/render.ts` from three + bundles into one, deleting the dead bridge branch and dead expando writes, + and splitting the trace overlay UI out of `core` all shrink the head-blocking + `tsjs-unified.js`. Target: measurably smaller than today's bundle, tracked in + CI (size report per PR). +- **Fewer repeated DOM walks.** Today slot resolution + (`findSlotElementByDivId`'s five-step ladder, iframe walks in the bridge, + prebid's independent re-derivation) runs per feature per pass. The slot + registry resolves once per slot per navigation and everyone reads the record. +- **Bounded waits instead of blind ones.** The 10 s silent renderer timeout and + the "queued forever on a GPT that never loads" cases become short, telemetered + timeouts with fallbacks — failures surface in hundreds of milliseconds, and + the render-source chain moves to the next source instead of waiting. + +**Where the design must not regress, and how that is enforced:** + +- **Ad request timing is untouched.** The critical path (bids script → + targeting → display/refresh) gains no network calls and no awaits; adapter + indirection is one property read and a queue check. +- **Telemetry is off the critical path by construction.** `sendBeacon` / + keepalive fetch, batched, flushed on `visibilitychange` — never awaited by + render code, capped in size and event count. +- **No new long tasks.** The kernel boots synchronously in microseconds + (queue + registry creation); integration `install()` bodies do what their + import-time footers do today, just at a controlled moment. +- **Budgets in CI:** bundle byte size per module, and a browser-spec assertion + that time-from-bids-script-to-first-`display()` on the reference page does + not regress against the recorded baseline. + +### 7.11 Toolchain and dependency currency + +The refactor starts from a current toolchain rather than dragging old versions +through it: + +- **TypeScript to latest stable.** The library pins `typescript ^5.5.4` while + the rest of the stack (vite 7, vitest 4, typescript-eslint 8) is current; + upgrade TS first and adopt the newer strictness the refactor wants anyway + (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, + `verbatimModuleSyntax`) — these directly serve the typing goals in 7.9 + (killing `as unknown as` escapes and the wrong `global.d.ts` declaration). +- **Dev toolchain to latest stable:** eslint (+ plugins), prettier, jsdom, + `@playwright/test` in the browser-test package, and `@types/node` aligned to + the pinned Node in `.tool-versions`. Each bump lands as its own mechanical PR + gated by the full CI matrix, with changelog review — this library + monkeypatches globals (`fetch`, `sendBeacon`, DOM prototypes), so jsdom and + Playwright behavior changes are real risks, not formalities. +- **`prebid.js` is deliberately excluded from casual bumps.** The runtime + Prebid is the external R2 bundle, version-locked by manifest hash and SRI; + the npm `prebid.js` dependency exists for tests and type + references. Upgrading Prebid is its own coordinated deploy (bundle + config + sha + server), per the decoupled-shim process — the spec only requires that + the npm pin and the deployed bundle version stay documented together so + tests exercise the version production runs. +- **Standing policy:** dependencies are reviewed on a monthly cadence and + before each phase of this migration begins; a phase never starts on a + toolchain more than one minor behind latest stable. Version floors live in + `package.json` (exact or caret pins as today) and CI runs on the pinned + Node/npm from `.tool-versions`. + +## 8. Migration plan (phased, each phase independently shippable) + +- **Phase 0 — Observability and toolchain.** Beacon + ingest route + server + drop-reason surfacing + reason codes on today's silent returns. Toolchain + currency (7.11): TypeScript and dev-dependency upgrades land here, before + any structural change, so every later phase type-checks against the compiler + it will ship with. No runtime behavior change. +- **Phase 1 — APS correctness.** Sections 6.1, 6.2, 6.5, 6.6 (admission, + token, endpoint, bridge). Verified by Phase 0 data and the new tests. This + phase alone should make APS render wherever configuration permits. +- **Phase 2 — GPT correctness.** Restore #922 attribution/orphan recovery + (with #997), correlator, SRA guard. Render-source chain + opt-in direct + fallback (6.3). +- **Phase 3 — Structure.** Layering + boundary lint, `install()` lifecycle, + adapters, slot registry, messaging module, schema generation (6.4). +- **Phase 4 — Decomposition.** File splits, script-guard consolidation, + bootstrap shrink. Pure moves under the existing vitest + browser specs, + landed one module per PR. + +Each phase gates on: all existing CI (Rust + JS + browser specs) green, plus +its own new tests; no phase depends on a later one. + +--- + +## 9. Alternatives considered + +1. **Keep patching APS point-failures without telemetry.** Rejected: three + consecutive correct fixes have not produced ads; without disposition data + the next fix is another guess. +2. **Direct-render APS always (skip GAM/PUC).** Simplest render path, but + changes GAM reporting/pacing semantics unilaterally; kept as the opt-in + fallback (6.3) instead. +3. **Full library rewrite in one branch.** Rejected: the browser-spec safety + net is thin in exactly the areas being changed; phased extraction under + tests is slower but survivable. +4. **Drop the ES5 bootstrap entirely (bundle-only).** Cleanest, but loses the + pinned "ads still render if the bundle fails" guarantee; the + generated-fallback approach (7.6) keeps that guarantee without the dual + maintenance. + +--- + +## 10. Risks + +- **Mediator merge policy (6.1)** changes auction economics where a mediator is + configured; mitigated by the explicit `mediator_only` strategy and the + delivery-report visibility. +- **Beacon volume**: bounded by batching, sampling, and closed enums; the + ingest route is fire-and-forget and cannot block rendering. +- **Schema generation** adds a build step; mitigated by checking generated + artifacts into the tree and diffing them in CI. +- **Bootstrap shrink** touches the most load-order-sensitive code in the + product; it is deliberately last (Phase 4) and behind the browser specs. + +## 11. Success criteria + +1. APS creatives render on a reference page in each configured flow (SSAT, + Prebid adapter, page-bids), proven by browser specs and by disposition + telemetry from a staged deployment. +2. Every failure point in section 2 maps to a distinct, observable signal + (server log, telemetry row, or beacon reason). +3. `eslint` boundary rules pass with zero exceptions; no integration imports + another integration; `core`/`kernel` imports no integration. +4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or + generated. +5. Trace counts are per-impression (no double counting), and orphaned-slot + recovery is covered by a non-vacuous test. +6. The only TSJS-owned global is `window.tsjs`; no expando properties on GPT + slots, GPT functions, or `pbjs`; the dead `__tsRenderGeneration` / + `__tsRenderBid` writes are gone. +7. The synchronous bundle is no larger than today's (target: smaller), and the + reference-page time-from-bids-script-to-first-`display()` does not regress. +8. No existing warning is lost: every issue-surfacing condition logs at `warn` + or above in the console, with the same reason code the beacon carries. +9. TypeScript and the dev toolchain are on latest stable (with the new + strictness flags enabled), `prebid.js`'s npm pin matches the documented + deployed bundle version, and the monthly review policy is in CI docs. + +## 12. Open questions + +1. Is a mediator configured in the affected production deployment? (Decides + whether A1 is the primary cause or a latent one.) +2. What share of live APS demand is `tagtype: "script"`? (Decides how urgent + the `allow_script_creatives` enablement guidance is.) +3. Should the direct-render fallback (6.3) ever become default-on for + publishers without GAM line items for `hb_bidder=aps`? +4. Is PR #997 the intended restoration of the lost #922 attribution core, or + should the original be re-merged? +5. Beacon endpoint naming and retention: `/_ts/client-events` vs folding into + the existing telemetry namespace. From 4f817d02799a76886cddc50bed5c3cb9413e7cdf Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:01:40 -0700 Subject: [PATCH 002/194] Rework design spec after review: settle the five architectural contracts Revision 2 addresses every blocking finding from the design review: adds a client-born random trace_id envelope and drops the EC-derived auction id from ingestion, scopes the render token to renderer-only bids so the PBS Cache uuid contract is untouched, replaces the aps index.ts assumption with a versioned window.tsjs._internal runtime ABI for the IIFE build, specifies an exactly-once render state machine with honest accepted-vs-confirmed semantics and nonempty-GAM protection, keeps mediator_only as the default with merge as defined opt-in, withdraws the containment dimension rule for operator-declared size maps, renames the unobservable 404 reason to renderer_no_ready, moves schema generation to a separate wire-schema crate with semantic validators retained, keeps ownership-first bridge validation over port-plus-token trust, and adds the ingest/deployment contracts (credentials-omit transport, four-adapter routing, content-addressed asset fix), a test acceptance matrix, and reordered phases with flags and rollback criteria. --- ...s-render-fix-and-tsjs-resilience-design.md | 936 +++++++++++------- 1 file changed, 566 insertions(+), 370 deletions(-) diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index d8550b05a..6415d36d8 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1,13 +1,16 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** draft for review +- **Status:** revision 2 — reworked after design review (review verdict: + request changes). The five architectural contracts the review required are + settled in section 4. - **Date:** 2026-08-04 -- **Baseline:** `rc/july` @ `541298695` — the full merged state (everything merged - from `main` plus every rc-only merge), not just the delta pending against - `main`. +- **Baseline:** `rc/july` @ `541298695` — the full merged state (everything + merged from `main` plus every rc-only merge), not just the delta pending + against `main`. - **Inputs:** three code audits performed against this baseline (APS end-to-end - trace, TSJS architecture audit, GPT integration map); open issues #926, #941, - #944, #962, #964, #977, #983, #989, #993; open PR #997. + trace, TSJS architecture audit, GPT integration map); design review of + revision 1; open issues #926, #941, #944, #962, #964, #977, #983, #989, + #993; open PR #997. --- @@ -39,7 +42,9 @@ integration does not reproduce this failure class. - No change to the APS OpenRTB endpoint contract or Amazon-side configuration. - No rewrite of Prebid.js integration strategy (the decoupled shim stays). -- No visual/behavioral change for publishers whose pages work today. +- No behavior change for publishers whose pages work today. Where this design + must migrate a public surface (section 7.4), it does so behind a bounded + compatibility window, never by immediate removal. --- @@ -59,7 +64,7 @@ The failure points, ranked by likelihood and blast radius: | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | | A1 | **A configured `[auction].mediator` discards every direct-provider bid.** Winners come exclusively from the mediator response; APS bids (with their renderers) are used only as mediator input and reporting. APS shows `status: success, bid_count: N` yet never wins a slot. | `orchestrator.rs:412-431` | | A2 | **`allow_script_creatives` defaults to `false`**, dropping every `tagtype: "script"` APS bid — a large share of TAM demand. The drop is counted but invisible (see A4). | `aps.rs:141-143`, `:773-778` | -| A3 | **Strict per-bid gates**: exact `w`×`h` match against configured formats (a 300×600 answer on a `[[300,250],[728,90]]` slot dies), required `ext.creativeurl`, and any top-level `contextual` key rejects the entire response. | `aps.rs:657-668`, `:745-778`, `:838-846` | +| A3 | **Strict per-bid gates**: exact `w`×`h` membership in the slot's configured formats, required `ext.creativeurl`, and any top-level `contextual` key rejects the entire response. | `aps.rs:657-668`, `:745-778`, `:838-846` | | A4 | **Drop reasons never reach an operator on the production paths.** `drop_reasons` counters surface only in `/auction` `ext.orchestrator`; the SSAT and page-bids paths discard them, server logs do not carry them, and the `ts-debug` comment allowlist excludes them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | ### 2.2 Identity: the `hb_adid` contract with GAM is unproven @@ -74,7 +79,7 @@ The failure points, ranked by likelihood and blast radius: | # | Failure | Where | | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | C1 | **If GAM never serves the Prebid Universal Creative, nothing renders and nothing is recorded.** The renderer descriptor sits unused in `window.tsjs.bids`; `renderApsCreative` is reachable only from the unused flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` | -| C2 | **The `/integrations/aps/renderer` route registers only when `[integrations.aps]` is enabled on that origin.** A 404/401 there means the sandboxed iframe waits 10 s and dies silently. | `aps.rs:1188-1245`, `aps/render.ts:404` | +| C2 | **A renderer endpoint that never answers is a silent 10-second death.** The sandboxed iframe cannot read an HTTP status from its opaque origin; a 404/401/misrouted document simply never posts `renderer-ready`. | `aps.rs:1188-1245`, `aps/render.ts:384-404` | | C3 | **SafeFrame breaks slot attribution.** The bridge resolves a message source by walking top-document iframes under the slot div; a nested SafeFrame creative window is invisible to that walk, so the bridge bails silently. | `gpt/index.ts:157-183`, `:1599-1600` | | C4 | **Three hand-maintained copies of the descriptor schema** (Rust struct, TS validator, inline renderer-document validator) with exact-key rejection: any server-side field addition instantly blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` | | C5 | **A dead duplicate renderer branch in the bridge** contains the debug log and dedup logic people would look for while debugging; it can never execute. | `gpt/index.ts:1643-1674` | @@ -86,9 +91,7 @@ The failure points, ranked by likelihood and blast radius: There is **zero client→server reporting**. Server telemetry marks `is_win=1` at auction time and goes quiet; a bid that never painted is byte-identical to one that painted perfectly. Client-side evidence (`window.tsjs.renders`, console -warnings, `data-ts-*` attributes) dies with the tab. This is why "APS still does -not work" has taken weeks instead of a dashboard row saying -`render_fail{renderer_endpoint_404}`. +warnings, `data-ts-*` attributes) dies with the tab. --- @@ -125,166 +128,355 @@ deciding the winner. The audit's key facts: coordinate via window-global booleans that async wrappers observe already reset. -Any APS fix that adds more targeting keys, more refresh calls, or more -postMessage traffic has to land inside this reality, which is why the design -couples the APS fix to the library restructuring instead of adding a sixth -patch to the pile. - --- -## 4. Design overview - -Three workstreams, ordered by dependency: - -1. **See the failures** — client→server disposition telemetry plus surfacing - the server's existing drop counters. Without this, every subsequent fix is - another blind patch. -2. **Fix APS delivery** — admission, identity, render chain, schema, and - bridge fixes, each verifiable by the new telemetry and by contract tests. -3. **Restructure TSJS** — the kernel/adapters/services architecture that makes - the fixes durable and the next integration cheap. +## 4. Design gates — the five contracts settled before implementation + +The design review identified five architectural contracts every later section +depends on. This revision settles them; changing any of these later reopens the +review. + +### G1 — Trace identity and event envelope + +**The client-visible auction id must never be ingested.** It is EC-derived by +construction (`publisher.rs:3237`: `ts-{ec_id}` when an EC exists), and server +telemetry already uses a deliberately unrelated fresh UUID +(`telemetry.rs:94-97`), so a join on it is both a privacy violation and +impossible. + +Contract: + +- A **`trace_id`** is minted client-side per navigation: 128-bit CSPRNG + (`crypto.getRandomValues`), hex-encoded, held in `PageSession`, never + persisted, never derived from any identity. +- Every event carries the envelope `{trace_id, nav_gen, render_gen, seq}`. + `seq` is a per-trace monotonic counter (ordering + deduplication); + `nav_gen`/`render_gen` scope events to a navigation and a refresh cycle, so a + batch that spans SPA navigations remains attributable per event, not per + batch. +- The **server** stamps the same `trace_id` into its own telemetry rows by + reading it from the beacon, never the reverse: server auction rows keep their + independent telemetry UUID, and correlation happens only where the client + reported a `trace_id` alongside the events it observed. Failures with no + winning bid therefore still have a trace: the trace is client-born, not + auction-born. +- **Sampling is sticky per trace** (decided once at trace mint, recorded in the + envelope), never per event — a sampled trace is complete or absent. + +### G2 — Render identity: `hb_adid`, the APS token, and the PBS Cache UUID + +**The PBS Cache contract stays untouched.** Today `hb_adid` deliberately +prefers the Prebid Cache UUID (`publisher.rs:3355`), and both the emitted cache +coordinates and the bridge's cache fetch assume `?uuid=` +(`publisher.rs:3450`, `gpt/index.ts:1700`) — that is the Prebid Universal +Creative's documented contract. Revision 1's "token for every SSAT bid" would +have broken every cache-backed render and is withdrawn. + +Contract: + +- Bids with a cache id: `hb_adid` = cache UUID, exactly as today. +- Bids with markup and no cache id: `hb_adid` = existing fallback chain + (`ad_id`, then bid id), exactly as today. +- **Renderer-only bids (APS): `hb_adid` = a server-minted render token**, + format `^[a-z0-9]{12}$` (12 chars exactly), CSPRNG-generated with collision + retry within the auction, unique across slots and auctions, one-time + consumption in the bridge registry, TTL-bounded. Twelve characters sit well + inside GAM's documented 40-character targeting-value limit. +- The client-side Prebid adapter path keeps Prebid's generated `adId` (that is + Prebid's own contract); both paths register into one bridge registry keyed by + whichever id that path will observe. +- Regression tests: non-APS cache-backed bids keep byte-identical `hb_adid` + and cache coordinates; the token property test asserts `^[a-z0-9]{12}$`, + uniqueness, one-time consumption, and TTL expiry. + +### G3 — Runtime ABI: how code shares state under the IIFE build + +Every entry point is built as a self-contained IIFE with dynamic imports +inlined (`build-all.mjs:46`), and the server concatenates already-closed IIFEs +(`bundle.rs:23`). **A module `import` therefore never shares state across +bundles** — an `aps/index.ts` alone would just mint another private copy. This +is already a live defect beyond APS: `core/context.ts:11` holds a private +context-provider `Map` while `permutive/index.ts:102` registers into its own +independently bundled copy. + +Contract — **a versioned registration ABI on `window.tsjs._internal`**: + +- The kernel ships **only** in `tsjs-core` (always first in the concatenated + unified bundle) and publishes `tsjs._internal = { abi: 1, registry }` exactly + once, guarded by a window-level sentinel. +- All **stateful** services (slot registry, render state machine, APS renderer + registry, context providers, event bus, beacon queue) exist once, owned by + the kernel, and are reached **only** through + `tsjs._internal.registry.get(name, minVersion)` at call time — never through + imported module state. Pure stateless helpers may still be imported and + inlined freely. +- Deferred bundles (prebid) and later scripts interact through the same ABI; + `abi` majors are checked at lookup and a mismatch is a logged, telemetered + refusal, not a silent no-op. +- The alternative (single module graph / shared chunks emitted by the build and + loaded as real modules) is recorded as the long-term option; the ABI is + chosen now because it works under today's concatenation and deferred + loading without changing the delivery pipeline. +- This gate precedes and unblocks: the event bus, the slot registry, the + install registry, `PageSession`, the messaging service, and fixes the + context-provider split as its first proof. + +### G4 — Exactly-once render state machine, and honest success semantics + +**Absence of a bridge request is not proof GAM failed** — GAM may legitimately +serve a different, higher-priority creative without ever invoking the TS +bridge. And **`renderer-ready` means Amazon's runner script loaded, not that an +ad painted** (`aps.rs:105`); the current direct renderer returns `true` +immediately while the real outcome settles asynchronously for up to ten +seconds (`render.ts:318`). + +Contract: + +- One render state machine per placement instance, keyed by + `(trace_id, nav_gen, slot, refresh_gen, render_token)`, with exactly-once + terminal transition. States: + `targeting_set → bridge_claimed | gam_filled_other | fallback_running → +render_accepted → render_confirmed | render_failed(reason)`. +- **The fallback never replaces a nonempty GAM render.** Any bridge claim or + nonempty `slotRenderEnded` cancels a pending fallback; an **empty** + `slotRenderEnded` may trigger it; navigation, refresh, handoff, and slot + destruction invalidate stale attempts. +- The renderer API becomes awaitable with cancellation and an explicit + terminal reason; no fire-and-forget `true`. +- Event taxonomy replaces the single `render_ok`: + - `bridge_response_sent` — the bridge handed a payload to the PUC; + - `render_accepted` — the renderer document loaded Amazon's runner + (today's "ready"); + - `render_confirmed` — observed evidence of a paint (nonempty + `slotRenderEnded` for GAM fills; for the sandboxed renderer, iframe + load + non-collapsed geometry where observable). +- **Billing/win callbacks fire no earlier than `render_accepted`, and + `render_confirmed` is the only state reported as success.** Where APS + provides no paint acknowledgement, the design says so plainly: telemetry can + distinguish "runner loaded" from "nothing loaded," but **cannot distinguish + a painted ad from a blank one** inside the opaque frame — rows terminate at + `render_accepted` and are labeled as such, not upgraded. + +### G5 — Deployment contracts + +- **Config rollback:** every new auction/config field is default-valued and + **omitted from serialization at its default** (`AuctionConfig` denies + unknown fields, `auction_config_types.rs:7`), so blobs written by a new + binary remain readable by the previous one unless an operator opts in. +- **Ingest routing:** the beacon route exists in **all four adapters** + (Fastly, Axum, Cloudflare, Spin) as an early, EC-free, filter-free route. + Only Fastly has a real telemetry sink today; the no-sink behavior elsewhere + is "accept, count, drop" — defined, not accidental. +- **Storage:** a new dedicated datasource for client events (the existing + auction datasource cannot hold the shape without migration); schema, + retention, and sampling documented with the datasource definition. +- **Asset content-addressing is fixed, not assumed.** Script URLs embed a + content hash but the handler ignores the query and serves current registry + bytes from the path alone (`tsjs.rs:3`, `publisher.rs:294`) — during a + rolling deploy an old `?v=A` request can receive bytes `B` and cache them + under `A`. The handler must validate the requested hash and serve the + matching immutable artifact (or answer with the current hash's redirect); + rolling-deploy cache tests pin this. +- **Phase ordering** follows section 9; every phase ships behind a feature + flag with canary thresholds and rollback criteria. --- -## 5. Workstream 1 — Observability first +## 5. Workstream 1 — Observability ### 5.1 Client disposition beacon -A new kernel module batches disposition events and posts them to a new -`POST /_ts/client-events` ingest route: +A kernel module batches disposition events to `POST /_ts/client-events`: ``` -{ v: 1, page: {auctionId, navGen}, events: [ - { t: "bid_received", slot, bidder, source } - { t: "targeting_set", slot, hbAdid } - { t: "bridge_request", slot, adId, matched: bool } - { t: "render_attempt", slot, source: "renderer"|"adm"|"pbs-cache" } - { t: "render_ok", slot, source } - { t: "render_fail", slot, source, reason } // reason is a closed enum +{ v: 1, trace: {trace_id, sampled}, events: [ + { seq, nav_gen, render_gen, t: "bid_received", slot, bidder, source } + { seq, nav_gen, render_gen, t: "targeting_set", slot, hbAdid } + { seq, nav_gen, render_gen, t: "bridge_request", slot, adId, matched } + { seq, nav_gen, render_gen, t: "bridge_response_sent", slot, source } + { seq, nav_gen, render_gen, t: "render_attempt", slot, source } + { seq, nav_gen, render_gen, t: "render_accepted", slot, source } + { seq, nav_gen, render_gen, t: "render_confirmed", slot, source } + { seq, nav_gen, render_gen, t: "render_fail", slot, source, reason } ] } ``` -- Transport: `navigator.sendBeacon` with `fetch` keepalive fallback; batched - (flush on `visibilitychange`/`pagehide` and every 5 s); capped payload. -- Server side: a bounded, sampled log/telemetry row per event class, joining on - the auction id the server already logs. No KV writes, no PII, no cookies. -- The existing `recordRender` funnel becomes a producer for this beacon, so the - in-page trace overlay and the server see the same stream. - -`reason` enums are the contract: `renderer_endpoint_404`, -`renderer_ready_timeout`, `descriptor_invalid`, `bridge_id_mismatch`, -`gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, and so on. -Every silent `return` found by the audit gets a reason code. - -### 5.2 Surface the server's own drop counters - -- Log `drop_reasons` at `warn` when an APS response yields zero admitted bids. -- Add `drop_reasons` to auction telemetry rows and to the `ts-debug` comment - allowlist (SSAT and page-bids paths). -- Startup validation warning when `[integrations.aps]` is enabled while - `allow_script_creatives = false`: "script-type APS demand will be dropped." -- Startup validation warning when APS (or any direct provider) is configured - alongside a mediator, until Workstream 2 makes that combination meaningful. - -**Exit criterion:** an operator can answer "which of the failure points in -section 2 is firing on this page" from server logs alone, with one page load. +- **Transport:** `fetch(..., {keepalive: true, credentials: "omit"})` is the + primary transport, because `sendBeacon` always sends credentials and its + `true` return only means "queued," not "received." `sendBeacon` remains the + documented last-resort fallback on `pagehide` where keepalive is + unavailable, and the ingest handler ignores credentials in all cases. +- **Batching:** flush on `visibilitychange`/`pagehide` and every 5 s; each + event self-describes its navigation via the envelope, so batches spanning + SPA navigations stay attributable. +- **Reasons are a closed enum**, structurally serialized (never interpolated + into log lines): `renderer_no_ready`, `descriptor_invalid`, + `bridge_id_mismatch`, `gam_empty`, `gam_filled_other`, `no_render_source`, + `slot_unresolved`, `gpt_absent`, `pbjs_absent`, `bundle_partial`, + `fallback_cancelled`, `timeout`. `renderer_no_ready` (not + `renderer_endpoint_404`) is deliberate: the opaque iframe cannot read an + HTTP status, so the observable fact is "no ready message before timeout." + +### 5.2 Ingest contract + +- Registered in all four adapters before auth/EC/filters (G5); same-origin + enforced via `Origin`/`Sec-Fetch-Site` checks; hard caps on body bytes, + event count, and string lengths **enforced before parsing or logging**; + malformed rows dropped and counted; per-IP rate limiting; responds + `204 Cache-Control: no-store`; touches no KV and mints no identity. +- Server logs a bounded, structured summary per batch; the Fastly sink writes + to the new datasource (G5); other adapters count-and-drop until a sink + exists. + +### 5.3 Two modes, honestly separated + +A sampled, best-effort beacon **cannot** guarantee a complete diagnosis from +one page load — so the design stops claiming it: + +- **Production telemetry:** sticky-sampled traces, SLO "a delivery failure + mode occurring on ≥ N% of impressions is visible in the datasource within + one hour." +- **Diagnostic mode:** explicitly enabled (tester cookie / query flag), + unsampled, full event stream plus console mirroring — this is the "one page + load tells you which failure fired" tool. + +### 5.4 Server-side drop-reason surfacing + +- Emit a bounded structured summary **whenever any bid is dropped** (not only + when zero survive): per-slot reason counts, capped. +- Add `drop_reasons` to auction telemetry rows. +- The initial-HTML `ts-debug` comment gains the drop summary; `/_ts/page-bids` + returns JSON and **cannot carry an HTML comment**, so it gains a gated + structured `debug` field instead, enabled by the same tester gate. +- Startup validation warnings: APS enabled while `allow_script_creatives = +false` ("script-type APS demand will be dropped"); any direct provider + configured alongside a mediator without the merge strategy of 6.1 ("provider + bids cannot win as configured"). --- ## 6. Workstream 2 — APS delivery fixes -### 6.1 Admission - -- **Mediated auctions must not discard direct-provider winners (A1).** New - winner-merge policy: after the mediator responds, direct-provider bids - compete per slot by decoded CPM against mediator bids under a configurable - strategy: `mediator_only` (today's behavior, explicit), `merge_highest_cpm` - (new default). The delivery report gains - `dropped_winner_reasons["mediator_superseded"]` so the loser is visible. -- **Dimension tolerance (A3).** Replace exact `w`×`h` equality with a - containment rule: an APS bid is admitted when its size fits within any - configured format for the slot (never larger on either axis); the served size - is reported in targeting. Exact match stays preferred when available. -- **Script creatives (A2).** Keep the secure default (`false`) but make the - consequence loud (5.2) and document the enablement path for TAM-heavy - publishers. The renderer sandbox already isolates script tag types; this is a - policy toggle, not new machinery. - -### 6.2 Render identity: one short token - -Introduce a server-generated **render token** — 12 chars, `[a-z0-9]`, unique per -(auction, slot) — emitted as `hb_adid` for every SSAT bid and used as the key in -every registry and bridge branch: - -- Well inside GAM's 40-char value limit and charset rules (B1). -- The bid map carries `{ hb_adid: token, bid_id, renderer, … }`; the bridge - matches on the token; billing/win URLs keep using the real bid id. -- The client-side Prebid adapter path keeps Prebid's generated `adId` (that - contract is Prebid's own), but registration for both paths lands in **one** - registry keyed by whichever token the path will observe (B2). -- Property test: every emitted `hb_adid` matches `^[a-z0-9-]{1,40}$`. - -### 6.3 Render source chain with a GAM-claim timeout - -A winning bid becomes an ordered list of render sources: -`renderer → inline adm → pbs-cache`. The render engine walks the chain, emitting -`render_attempt` / `render_ok` / `render_fail{reason}` per step. - -For flow (a)/(c), add the missing fallback (C1): when targeting was set for a -slot and **no bridge request arrives within N seconds of `slotRenderEnded` -(empty) or within M seconds of refresh**, and the config opts in -(`[auction].client_render_fallback = "renderer"`), render the descriptor -directly into the slot container via the existing `renderApsCreative` path. The -fallback is opt-in because it changes GAM reporting semantics; the beacon makes -the "GAM never asked" case visible either way. - -### 6.4 One descriptor schema - -The Rust `ApsRendererV1` struct becomes the single source of truth: - -- `build.rs` (or a checked-in generation step) exports JSON Schema from the - serde model; the TS types and validators in `aps/render.ts` and the inline - renderer-document validator are **generated** from it. -- Validation becomes versioned-envelope tolerant: known fields validated - strictly, unknown fields ignored, `version` gates behavior (C4). -- A conformance test round-trips a Rust-serialized descriptor through the TS - validator and the renderer-document validator in CI. - -### 6.5 Renderer endpoint availability - -- Register the `/integrations/aps/renderer` route whenever the server can emit - renderer bids (auction-level concern), not only when the APS integration is - enabled on the serving origin (C2). -- The renderer iframe failure path (10 s timeout, load error) emits - `render_fail{renderer_endpoint_404 | renderer_ready_timeout}` instead of - dying silently. -- CSP audit (C6): extend `APS_RENDERER_CSP` with the minimum additional sources - observed in real Amazon creative traffic (candidates: `frame-src data: blob:`, - `worker-src blob:`), each addition justified in a comment and covered by the - browser spec. - -### 6.6 Bridge hardening - -- Delete the dead duplicate renderer branch (C5) and move its dedup + - debug-log into the live branch. -- Renderer branches call the same `fireWinBillingBeacons` + - `recordGptBridgeRender` as the adm and cache branches (C7). -- Blanket source validation at the top of the bridge listener: parse and - ownership-check before any branch logic; new branches inherit protection. -- SafeFrame-aware attribution (C3): resolve the slot by the MessageChannel port - and the `hb_adid` token first (the token is already unique per slot), using - the DOM walk only as a fallback. - -### 6.7 Tests that pin the contract - -1. Browser spec for flow (a): real GPT + PUC handshake driven from - `window.tsjs.bids` with a renderer-only bid (the region the dead code hid). -2. Mediator + APS orchestration test asserting `merge_highest_cpm` admits the - APS winner and `mediator_only` reports the drop. -3. `build_bid_map` tests: renderer emission, token-form `hb_adid`, adm and - cache-coordinate suppression for renderer bids. -4. Cross-schema conformance (6.4). -5. Page-bids JSON carries `renderer`; SPA hook delivers it to the bridge. +### 6.1 Mediation: opt-in merge, `mediator_only` stays the default + +The configured contract today is explicit — the mediator is the final +decision-maker (`auction_config_types.rs:48`) — and changing that default +silently would be an economic breaking change with a rollback hazard. So: + +- `[auction].winner_selection = "mediator_only"` (default, today's behavior, + now explicit) or `"merge_highest_cpm"` (opt-in). The field is omitted from + serialized blobs at its default (G5). +- `merge_highest_cpm` semantics, defined up front: comparison in the auction's + decoded CPM currency; slot floors apply to both populations; a bid present + in both (a provider bid the mediator also returned) counts once, by + provenance `mediator`; deals outrank open bids regardless of CPM; ties break + to the mediator; a mediator timeout degrades to direct-provider selection + (today's short-circuit) and is reported as such. +- **One candidate-selection helper serves both mediation lifecycles** — the + ordinary/page-bids path (`orchestrator.rs:412-431`) and the initial-SSAT + split dispatch/collect path (`orchestrator.rs:1320`) — with tests for each. +- A **selection report** (`winner_source`, `mediator_superseded` counts) is + emitted separately from the delivery-conversion drop reasons, so "lost the + merge" is never conflated with "failed to serialize." + +### 6.2 Dimensions: exact membership stays; flexibility is operator-declared + +Revision 1's containment rule ("never larger on either axis") would still +reject its own motivating example (a 300×600 answer on a 300×250/728×90 slot) +while admitting pathological 1×1 sizes — withdrawn. Instead: + +- Exact size membership (`aps.rs:657-668`) remains the default; it is + consistent with discrete GAM slot formats. +- An operator may declare flexibility per slot: + `accept_sizes = [[300, 600], …]` (an explicit allow-list of additional + creative sizes, with documentation that GAM line items must accept them) — + no inference, no aspect heuristics in v1. Admitted alternate sizes set + `hb_size` targeting (set and cleared with the other `hb_*` keys) and the + served size is reported in the selection report. + +### 6.3 Script creatives + +Keep the secure default (`allow_script_creatives = false`) but make the +consequence loud (5.4) and document the enablement path for TAM-heavy +publishers. The renderer sandbox already isolates script tag types; this is a +policy toggle, not new machinery. + +### 6.4 Render identity + +Implemented exactly as G2: cache UUID untouched, render token only for +renderer-only bids, one registry, token property tests +(`^[a-z0-9]{12}$`, CSPRNG, collision retry, TTL, one-time consumption, +cross-slot/auction uniqueness), and a non-APS cache-path regression test. + +### 6.5 Fallback rendering + +Implemented exactly as G4: opt-in +(`[auction].client_render_fallback = "renderer"`), driven by the render state +machine, triggered only by an **empty** GAM render or a bridge-claim timeout +with no fill evidence, cancelled by any bridge claim or nonempty render, +invalidated by navigation/refresh/destruction, and reported with the G4 event +taxonomy. The direct renderer is converted to an awaitable API first; the +fallback lands only after that conversion. + +### 6.6 Renderer endpoint + +- Document the topology: within one deployment the renderer route and the APS + provider share the same config gate (`aps.rs:1224`, `:1244`), so "server + emits descriptors but lacks the route" is a **cross-deployment or stale-CDN + problem**, and a 401 most plausibly comes from broad `[[handlers]]` auth + patterns matching `/integrations/*` before route dispatch. +- Therefore: validate at startup that no configured auth handler pattern + covers `/integrations/aps/renderer`; make the static renderer document + config-independent **iff** the deployment serves multiple origins from one + config (recorded as an open question with the operator); version the + renderer document and define its cache headers. +- The client reports `renderer_no_ready` (5.1) — no status probe is added, + because a probe would violate the no-new-critical-path-request budget. +- CSP audit (C6) unchanged from revision 1: extend `APS_RENDERER_CSP` only + with sources observed in real Amazon traffic, each justified in a comment + and covered by the browser spec. + +### 6.7 One descriptor schema — structural generation, semantic validators kept + +- The wire truth is the **tagged enum** `BidRenderer` (the `type: "aps"` + discriminator lives there, not on `ApsRendererV1` — `types.rs:188-211`), so + the generated schema is the full tagged envelope. +- Generation lives in a **separate wire-schema crate** (or a host-side + xtask) — it cannot live in `trusted-server-js`'s build because core already + depends on that crate (`Cargo.toml:45`) and the reverse edge would be a + cycle. Generated TS artifacts are checked in; CI fails on staleness. +- Generation covers **structure only** (fields, types, discriminator, + version). The semantic security checks stay hand-written on both sides: + URL/origin policy, canonical base64, length bounds, the exact one-bid + envelope projection, cross-field equality. **Unknown-field tolerance applies + only to the outer versioned descriptor; the decoded AAX envelope remains an + exact projection** so `adm`, notification URLs, or sibling fields can never + slip through unexamined. +- A shared corpus — positive cases plus an adversarial set (extra fields, + wrong versions, oversized payloads, URL smuggling, non-canonical base64) — + runs through the Rust validator, the TS validator, and the inline renderer + document in CI. + +### 6.8 Bridge hardening + +- **Ownership proof stays source-first.** The bridge continues to resolve the + message source to a slot and only then compares that slot's expected id + (`gpt/index.ts:1599` order) — a MessageChannel port plus a token is not + ownership proof, because an inbound port has no pre-established slot + identity and the token is visible in `window.tsjs.bids`. For SafeFrame, + source resolution is extended to walk nested browsing contexts via + `window.frames` containment checks rather than DOM `querySelectorAll` only; + where the source is unresolvable the bridge refuses (with + `bridge_id_mismatch`) instead of trusting the token. +- Adversarial tests are part of the contract: wrong-slot tokens, nested + foreign frames, replayed and duplicated messages, stolen tokens, and + previous-navigation tokens — not only the positive SafeFrame case. +- Blanket top-of-listener hygiene: parse and ownership-check before any + branch logic; new branches inherit protection. +- Delete the dead duplicate renderer branch (C5); move its dedup and debug log + into the live branch. +- Renderer branches emit the same trace records as the adm and cache branches, + under G4's event taxonomy and billing rules (C7). --- @@ -304,95 +496,87 @@ Rules, enforced by an eslint boundary rule in CI (`import/no-restricted-paths`): - `kernel` imports nothing above it; `adapters` import kernel only; `services` import kernel + adapters; `integrations` import kernel + services, **never each other**. +- Stateful services are reached through the G3 registration ABI, never through + imported module state; stateless helpers may be imported and inlined. - This dissolves today's inversions: `core/auction.ts` and `core/request.ts` importing `integrations/aps/render`, `gpt` and `prebid` importing `aps`, and `prebid` owning the GPT refresh wrapper. -- `aps/` gains a real module boundary (an `index.ts`), ending the triple - inlining that gives three bundles three private copies of the frame-tracking - WeakMaps (today two paths can each mount a live APS iframe on one container - without seeing each other's cancel bookkeeping). -### 7.2 Adapters: explicit absence +### 7.2 Adapters: explicit absence, without giving up on late loaders -Every external global is wrapped once with a tri-state -(`present | pending | absent`), a queue for `pending`, and a resolution -timeout that emits telemetry on `absent`. No other file touches -`window.googletag` / `window.pbjs`. This converts today's silent hangs (GPT -stub whose `cmd` never drains, `adInit` bare-returning without googletag) into -recorded, reasoned outcomes. +Every external global is wrapped once with a state machine +`present | pending | timed_out`, a queue for `pending`, and per-operation +bounds. `timed_out` is **not terminal**: publishers legitimately lazy-load +GPT, Prebid, and CMPs, so a later arrival transitions the adapter to `present` +and drains what is still valid; individual queued operations carry their own +timeouts and expire with a disposition reason rather than the adapter +permanently disabling itself. ### 7.3 Slot registry service One registry owns all slot knowledge: publisher-defined vs TS-defined, adoption, handoff claims, responsive element resolution, refresh generation, targeting-key history — keyed by `WeakMap` plus a -div-id index. Expando properties on live GPT objects are eliminated. The GPT -integration feeds events in and executes registry decisions; the prebid refresh -handler consumes the same registry instead of re-deriving slot resolution. - -### 7.4 Global namespace policy: everything under `window.tsjs` - -Today the library sprawls across the window: ten-plus `window.__tsjs_*` / -`window.__ts*` flags, `globalThis.tscreative` / `tsCreativeConfig`, a -symbol-keyed dispatcher, and expando properties stamped onto foreign objects -(`__tsPushed` on GPT's command queue, `__tsSlotHandoffPatched` on wrapped -functions, `__tsRenderGeneration` / `__tsRenderBid` on live GPT slot objects, -sentinels on `pbjs`). The policy going forward: - -- **One owned global: `window.tsjs`**, split internally into `tsjs` (public, - versioned API) and `tsjs._internal` (coordination state, explicitly not a - contract). Server-injected boot flags (`__tsjs_gpt_enabled`, - `__tsjs_slim_prebid_url`, bundle manifests) become fields the boot script - sets via the same command-queue pattern (`window.tsjs = window.tsjs || -{cmd: []}`), so early inline scripts and the bundle share one namespace. -- **No expandos on objects we do not own.** Per-slot state - (`__tsRenderGeneration`, `__tsRenderBid`) moves into the slot registry's - `WeakMap`; wrap-idempotence sentinels on foreign - functions are replaced by a kernel-held `WeakSet` of wrapped targets. -- **Immediate cleanup, independent of the refactor:** `__tsRenderGeneration` - and `__tsRenderBid` are dead writes on this baseline — written at - `gpt/index.ts:1086-1091`, read by nothing (their consumer was lost in the - #922 merge). Delete the writes now; when Phase 2 restores attribution, the - captured bid/generation lives in `SlotRecord`. -- Third-party globals (`googletag`, `pbjs`, CMP APIs) are read only through - adapters (7.2); integration-owned config globals (`didomiConfig`, - `permutive.config`) are written only inside that integration's adapter - boundary. +div-id index, owned by the kernel via the G3 ABI. Expando properties on live +GPT objects are eliminated. The GPT integration feeds events in and executes +registry decisions; the prebid refresh handler consumes the same registry. + +### 7.4 Global namespace policy — with a compatibility window + +One owned global, `window.tsjs`, public API versioned, coordination state +under `tsjs._internal` (G3). But the migration must not break published +contracts: + +- **Inventory first:** every current global is classified public + (`globalThis.tscreative`, `tsCreativeConfig` — documented, settable + pre-load) or private (`__tsjs_*` flags, expandos, sentinels). +- **Public globals get a bounded dual-read/write window:** old and new names + both work for a stated deprecation period, with pre-init compatibility tests + (config set before the bundle loads must keep working); removal is its own + later, announced change. +- Private globals migrate immediately: per-slot expandos + (`__tsRenderGeneration`, `__tsRenderBid` — dead writes today, delete now) + into `SlotRecord`; function-object sentinels into a kernel-held `WeakSet`; + boot flags into the `window.tsjs = window.tsjs || {cmd: []}` pattern. ### 7.5 Messaging module All `postMessage` traffic goes through one module: versioned envelopes, message name constants (today `'Prebid Request'` appears as a bare literal at six -sites, and the APS handshake exists in three hand-synced copies), source and — -where origins are non-opaque — origin validation, and one audit point. - -### 7.6 Lifecycle discipline - -- **`install()` entry points instead of import-time side effects.** The server - boot script calls `tsjs.install(['gpt', 'prebid', …])`; modules stop - self-executing at module bottom. This kills the double-injection class - (today: `beacon_guard` double-wraps `window.fetch` unrecoverably, a second - `creative` copy silently disarms `setConfig`, `didomi` can throw during - module evaluation and halt the concatenated bundle). -- One shared window-level install sentinel helper (the pattern - `gpt_diagnostics` already got right), applied to every integration. -- A `PageSession` object owns all per-page mutable state; SPA navigation - disposes and recreates it (fixing the leaked observers, listeners, and - unbounded maps the audit enumerated). -- Error policy: no empty `catch` — every catch either handles, logs with - context, or emits a disposition reason. The auction fetch gets a timeout + +sites, and the APS handshake exists in three hand-synced copies), source +validation per 6.8, and one audit point. + +### 7.6 Plugin lifecycle + +`install()` replaces import-time side effects, with the semantics the review +demanded: + +- `tsjs.definePlugin(id, version, install, dispose)` registers; the kernel + resolves install requests against registrations, so **a deferred bundle that + registers after `tsjs.install([...])` was requested is installed on + arrival** (pending-install queue), bounded by a missing-module timeout that + emits `bundle_partial`. +- Per-plugin exception isolation: a plugin that throws during install is + quarantined and reported; it cannot halt the bundle (today `didomi` can + throw during module evaluation and stop everything after it). +- Duplicate registration of the same `(id, version)` is a no-op; a different + version for a registered id follows a declared policy (first-wins + loud + telemetry). +- A `PageSession` object owns an **enumerable** set of listeners, timers, + observers, and slot records — registered at creation, disposed on + navigation. A `WeakMap` alone cannot dispose anything; the owned-set is the + disposal inventory. +- Error policy: no empty `catch` — every catch handles, logs with context, or + emits a disposition reason. The auction fetch gets a timeout + `AbortController`, and `requestAds` surfaces failure to its caller. -- **Console logging is retained, not replaced.** The disposition beacon is - additive: every condition that surfaces an issue keeps (or gains) a - `log.warn` with enough context to debug from an open DevTools console, - because the console is the tool available on a publisher's page when no - server access exists. Concretely: existing warnings survive the refactor - verbatim or strengthened; failure paths currently logged at `debug` — which - is invisible at the default `warn` level (for example the creative - `dynamic_src_guard` and click-guard rejection paths) — are promoted to - `warn` when they indicate a delivery or security-relevant failure; and every - new `render_fail` / `absent`-dependency disposition emits a paired `warn` - carrying the same reason code, so console and beacon tell one story. +- **Console logging is retained, not replaced.** The beacon is additive: every + issue-surfacing condition keeps (or gains) a `log.warn` debuggable from an + open DevTools console. Existing warnings survive verbatim or strengthened; + failure paths currently at `debug` (invisible at the default `warn` level — + the creative `dynamic_src_guard` and click-guard rejection paths) are + promoted to `warn` when they indicate a delivery or security-relevant + failure; every `render_fail` / dependency-timeout disposition emits a paired + `warn` carrying the same reason code, so console and beacon tell one story. ### 7.7 The bootstrap problem @@ -401,24 +585,25 @@ initial-load detection, hydration deferral) in hand-written ES5, always wins the sentinel race, and has one live divergence (its simpler `adInit` can run first and permanently suppress the bundle's `slotRenderEnded` listener). -Target: shrink the inline bootstrap to a **queue-and-flags stub only** (create -`googletag.cmd` interception points, record early publisher calls, expose -`__tsjs_gpt_enabled`), and move all behavior into the bundle, which replays the -recorded early calls on install. If a no-bundle fallback must keep rendering -ads (today's pinned behavior), that fallback is **generated from the same -TypeScript source** at build time, never hand-maintained. +Target: shrink the inline bootstrap to a queue-and-flags stub (create +`googletag.cmd` interception points, record early publisher calls, expose the +enable flag), with the bundle replaying recorded calls on install. This is +**not a pure move** — replay changes observable ordering — so it ships behind +its own flag with the browser specs extended to cover replay timing, and the +no-bundle fallback ("ads still render if the bundle fails," pinned by +`gpt.rs:1174-1179`) is **generated from the same TypeScript source** at build +time, never hand-maintained. ### 7.8 GPT correctness fixes carried with the restructure - Restore the #922 orphan-slot recovery and `updateRender` enrichment (verify - against open PR #997; land whichever is canonical) — fixes the dead-element - handoff alias (section 3.2) and trace double-counting. -- Pass `changeCorrelator: false` on TS-initiated refreshes; make correlator - behavior a documented, configurable decision. + against open PR #997; land whichever is canonical). +- Pass `changeCorrelator: false` on TS-initiated refreshes; correlator + behavior becomes a documented, configurable decision. - `enableSingleRequest()` only when GPT services are not already enabled; otherwise adopt the publisher's mode and record it. -- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` instead - of only a console warning. +- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` in + addition to its console warning. ### 7.9 Decomposition targets @@ -430,162 +615,173 @@ TypeScript source** at build time, never hand-maintained. | `core/trace.ts` (record model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | | `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split into public API vs internal coordination state | ---- - ### 7.10 Performance -Resilience must not cost speed; several parts of this design make the library -faster, and the rest are held to explicit budgets: - -**Where the design is a speedup:** - -- **Smaller synchronous bundle.** Consolidating `gpt/script_guard.ts` (634 - lines) onto the shared factory, un-inlining `aps/render.ts` from three - bundles into one, deleting the dead bridge branch and dead expando writes, - and splitting the trace overlay UI out of `core` all shrink the head-blocking - `tsjs-unified.js`. Target: measurably smaller than today's bundle, tracked in - CI (size report per PR). -- **Fewer repeated DOM walks.** Today slot resolution - (`findSlotElementByDivId`'s five-step ladder, iframe walks in the bridge, - prebid's independent re-derivation) runs per feature per pass. The slot - registry resolves once per slot per navigation and everyone reads the record. -- **Bounded waits instead of blind ones.** The 10 s silent renderer timeout and - the "queued forever on a GPT that never loads" cases become short, telemetered - timeouts with fallbacks — failures surface in hundreds of milliseconds, and - the render-source chain moves to the next source instead of waiting. - -**Where the design must not regress, and how that is enforced:** - -- **Ad request timing is untouched.** The critical path (bids script → - targeting → display/refresh) gains no network calls and no awaits; adapter - indirection is one property read and a queue check. -- **Telemetry is off the critical path by construction.** `sendBeacon` / - keepalive fetch, batched, flushed on `visibilitychange` — never awaited by - render code, capped in size and event count. -- **No new long tasks.** The kernel boots synchronously in microseconds - (queue + registry creation); integration `install()` bodies do what their - import-time footers do today, just at a controlled moment. -- **Budgets in CI:** bundle byte size per module, and a browser-spec assertion - that time-from-bids-script-to-first-`display()` on the reference page does - not regress against the recorded baseline. +Client-side, the design is a net speedup with enforced budgets: + +- **Smaller synchronous bundle:** script-guard consolidation, single APS + module (via the ABI), dead-code deletion, trace-overlay extraction. +- **Fewer repeated DOM walks:** slot resolution once per navigation in the + registry. +- **Bounded waits instead of blind ones:** the 10 s silent renderer timeout + and forever-queued GPT cases become short, telemetered timeouts. +- **Budgets in CI, precisely specified:** per-bundle byte sizes measured raw, + gzip, and Brotli for an exact named module set, compared against a + checked-in baseline artifact with a stated tolerance; the browser-spec + timing assertion (bids-script-to-first-`display()`) runs N times and gates + on a percentile, not a single sample. + +Server-side (new in this revision): each injected page currently concatenates +and hashes the full immediate bundle, and the asset request concatenates it +again (`bundle.rs:51`). Precompute bundle bytes + hash per registry module set +(they change only at deploy/config time), and benchmark server CPU/heap before +and after. ### 7.11 Toolchain and dependency currency -The refactor starts from a current toolchain rather than dragging old versions -through it: - -- **TypeScript to latest stable.** The library pins `typescript ^5.5.4` while - the rest of the stack (vite 7, vitest 4, typescript-eslint 8) is current; - upgrade TS first and adopt the newer strictness the refactor wants anyway - (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, - `verbatimModuleSyntax`) — these directly serve the typing goals in 7.9 - (killing `as unknown as` escapes and the wrong `global.d.ts` declaration). -- **Dev toolchain to latest stable:** eslint (+ plugins), prettier, jsdom, - `@playwright/test` in the browser-test package, and `@types/node` aligned to - the pinned Node in `.tool-versions`. Each bump lands as its own mechanical PR - gated by the full CI matrix, with changelog review — this library - monkeypatches globals (`fetch`, `sendBeacon`, DOM prototypes), so jsdom and - Playwright behavior changes are real risks, not formalities. -- **`prebid.js` is deliberately excluded from casual bumps.** The runtime - Prebid is the external R2 bundle, version-locked by manifest hash and SRI; - the npm `prebid.js` dependency exists for tests and type - references. Upgrading Prebid is its own coordinated deploy (bundle + config - sha + server), per the decoupled-shim process — the spec only requires that - the npm pin and the deployed bundle version stay documented together so - tests exercise the version production runs. -- **Standing policy:** dependencies are reviewed on a monthly cadence and - before each phase of this migration begins; a phase never starts on a - toolchain more than one minor behind latest stable. Version floors live in - `package.json` (exact or caret pins as today) and CI runs on the pinned - Node/npm from `.tool-versions`. - -## 8. Migration plan (phased, each phase independently shippable) - -- **Phase 0 — Observability and toolchain.** Beacon + ingest route + server - drop-reason surfacing + reason codes on today's silent returns. Toolchain - currency (7.11): TypeScript and dev-dependency upgrades land here, before - any structural change, so every later phase type-checks against the compiler - it will ship with. No runtime behavior change. -- **Phase 1 — APS correctness.** Sections 6.1, 6.2, 6.5, 6.6 (admission, - token, endpoint, bridge). Verified by Phase 0 data and the new tests. This - phase alone should make APS render wherever configuration permits. -- **Phase 2 — GPT correctness.** Restore #922 attribution/orphan recovery - (with #997), correlator, SRA guard. Render-source chain + opt-in direct - fallback (6.3). -- **Phase 3 — Structure.** Layering + boundary lint, `install()` lifecycle, - adapters, slot registry, messaging module, schema generation (6.4). -- **Phase 4 — Decomposition.** File splits, script-guard consolidation, - bootstrap shrink. Pure moves under the existing vitest + browser specs, - landed one module per PR. - -Each phase gates on: all existing CI (Rust + JS + browser specs) green, plus -its own new tests; no phase depends on a later one. +- **TypeScript to latest stable** (library pins `^5.5.4` while vite 7 / + vitest 4 / typescript-eslint 8 are current), adopting + `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, + `verbatimModuleSyntax` — these directly serve killing the `as unknown as` + escapes and the wrong `global.d.ts` declaration. +- **Dev toolchain to latest stable** (eslint + plugins, prettier, jsdom, + `@playwright/test`, `@types/node` aligned to the pinned Node), each bump its + own mechanical CI-gated PR with changelog review — this library + monkeypatches `fetch`, `sendBeacon`, and DOM prototypes, so jsdom and + Playwright behavior changes are real risks. +- **`prebid.js` is excluded from casual bumps:** the runtime Prebid is the + external R2 bundle locked by manifest hash and SRI; upgrading it is its own + coordinated deploy. The npm pin and the deployed bundle version stay + documented together so tests exercise the version production runs. +- **Standing policy:** monthly dependency review; no migration phase starts + more than one minor behind latest stable. --- -## 9. Alternatives considered - -1. **Keep patching APS point-failures without telemetry.** Rejected: three - consecutive correct fixes have not produced ads; without disposition data - the next fix is another guess. -2. **Direct-render APS always (skip GAM/PUC).** Simplest render path, but - changes GAM reporting/pacing semantics unilaterally; kept as the opt-in - fallback (6.3) instead. -3. **Full library rewrite in one branch.** Rejected: the browser-spec safety - net is thin in exactly the areas being changed; phased extraction under - tests is slower but survivable. -4. **Drop the ES5 bootstrap entirely (bundle-only).** Cleanest, but loses the - pinned "ads still render if the bundle fails" guarantee; the - generated-fallback approach (7.6) keeps that guarantee without the dual - maintenance. +## 8. Migration plan + +Reordered so every phase's prerequisites precede it; each phase ships behind a +feature flag with canary thresholds and rollback criteria. + +- **Phase 0 — Contracts and toolchain.** Settle G1–G5 in code-adjacent docs; + toolchain upgrades (7.11); the trace envelope + beacon + four-adapter ingest + (accept-count-drop outside Fastly) behind a flag; server drop-reason + surfacing (5.4); reason codes on today's silent returns; delete the dead + expando writes. No runtime behavior change for pages with the flag off. +- **Phase 1 — Runtime ABI + APS admission/identity.** G3 kernel registry in + `tsjs-core` (context-provider fix is the proof); wire-schema crate + shared + corpus (6.7); mediation selection helper + opt-in merge (6.1); render token + for renderer-only bids (6.4); renderer-endpoint startup validation (6.6); + bridge hardening minus fallback (6.8). APS renders after this phase wherever + GAM line items and configuration permit — the no-GAM fallback is explicitly + Phase 2. +- **Phase 2 — Render state machine + GPT correctness.** Minimal `SlotRecord` + core (just enough for the state machine keys; full registry lands in + Phase 3); awaitable renderer conversion; the exactly-once fallback (6.5, + G4); restore #922/#997 attribution and orphan recovery; correlator and SRA + fixes (7.8). +- **Phase 3 — Structure.** Full layering + boundary lint, plugin lifecycle + + `PageSession` (7.6), adapters (7.2), full slot registry (7.3), messaging + module (7.5), namespace migration with its compatibility window (7.4), + asset content-addressing fix + server bundle precompute (G5, 7.10). +- **Phase 4 — Decomposition.** File splits (7.9), script-guard consolidation, + bootstrap shrink behind its own flag with replay-timing specs (7.7), and + the end of the public-global compatibility window. --- -## 10. Risks +## 9. Test acceptance matrix + +Blocking CI is hermetic (the deterministic PUC/message harness); a separate +staged smoke suite covers real GAM line items and is release-gating, not +PR-gating. + +| Area | Must cover | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Mediation | both lifecycles (ordinary + SSAT split dispatch/collect); ties, floors, deals, duplicate demand; `mediator_only` default preserved; rollback blob round-trip (omitted defaults) | +| Cache identity | non-APS cache-backed bids: byte-identical `hb_adid` + cache coordinates (regression); PUC `?uuid=` fetch path | +| Render token | `^[a-z0-9]{12}$`; CSPRNG source; collision retry; TTL; one-time consumption; cross-slot/auction uniqueness | +| Fallback | races vs bridge claim; nonempty-GAM protection; repeated refresh; SPA navigation cancellation; slot destruction; exactly-once terminal transition | +| Render semantics | no billing after runner-load failure; `render_accepted` vs `render_confirmed` labeling; opaque-frame honesty (no painted/blank claim) | +| Bridge security | wrong-slot tokens; nested foreign frames; replayed + duplicate messages; stolen tokens; previous-navigation tokens; SafeFrame positive case | +| Beacon | trace joins; ordering/dedup via `(trace_id, nav_gen, seq)`; batching across navigations; loss tolerance; ingest abuse (oversize, malformed, cross-origin); all-adapter routing | +| Schema | generated-artifact staleness; adversarial corpus through Rust + TS + inline validator; outer-tolerance vs exact AAX projection | +| Runtime ABI | one kernel instance under concatenation; deferred registration after install request; per-plugin failure isolation; abi-version mismatch refusal | +| Lifecycle | late-loaded GPT/pbjs (`timed_out → present`); `PageSession` disposal inventory; pre-init `tsCreativeConfig` compatibility; dual-name global window | +| Delivery | immutable-cache behavior across a simulated rolling deploy; deterministic raw/gzip/Brotli bundle budgets vs baseline artifact | +| Observability | drop-reason summary on any partial drop; page-bids structured `debug` field gating; diagnostic mode unsampled completeness | + +--- -- **Mediator merge policy (6.1)** changes auction economics where a mediator is - configured; mitigated by the explicit `mediator_only` strategy and the - delivery-report visibility. -- **Beacon volume**: bounded by batching, sampling, and closed enums; the - ingest route is fire-and-forget and cannot block rendering. -- **Schema generation** adds a build step; mitigated by checking generated - artifacts into the tree and diffing them in CI. -- **Bootstrap shrink** touches the most load-order-sensitive code in the - product; it is deliberately last (Phase 4) and behind the browser specs. +## 10. Alternatives considered -## 11. Success criteria +1. **Keep patching APS point-failures without telemetry.** Rejected: three + consecutive correct fixes have not produced ads; without disposition data + the next fix is another guess. +2. **Direct-render APS always (skip GAM/PUC).** Simplest render path, but + changes GAM reporting/pacing semantics unilaterally; kept as the opt-in, + state-machine-guarded fallback instead. +3. **Single module graph / shared chunks instead of the registration ABI.** + Cleaner long-term, but changes the delivery pipeline (chunk loading) now; + recorded as the successor option behind the same ABI surface. +4. **Full library rewrite in one branch.** Rejected: the browser-spec safety + net is thin in exactly the areas being changed. +5. **Drop the ES5 bootstrap entirely.** Loses the pinned "ads render if the + bundle fails" guarantee; the generated-fallback approach keeps it without + dual maintenance. + +## 11. Risks + +- **Merge strategy misconfiguration** changes auction economics; mitigated by + keeping `mediator_only` the default, the selection report, and omitted + serialization at defaults. +- **Beacon abuse/volume:** bounded by pre-parse caps, origin checks, rate + limits, sticky sampling, and closed enums. +- **ABI freeze risk:** `tsjs._internal.registry` becomes load-bearing; + versioned from day one, majors checked at lookup. +- **Bootstrap replay** changes observable ordering; own flag, replay-timing + specs, staged rollout. +- **Schema generation** adds a build step; checked-in artifacts + staleness CI. + +## 12. Success criteria 1. APS creatives render on a reference page in each configured flow (SSAT, - Prebid adapter, page-bids), proven by browser specs and by disposition - telemetry from a staged deployment. -2. Every failure point in section 2 maps to a distinct, observable signal - (server log, telemetry row, or beacon reason). + Prebid adapter, page-bids), proven hermetically in CI and by the staged + smoke suite against real GAM line items. +2. Every failure point in section 2 maps to a distinct observable signal, and + **diagnostic mode** yields the failing reason from one page load; production + telemetry meets the stated SLO (5.3). 3. `eslint` boundary rules pass with zero exceptions; no integration imports - another integration; `core`/`kernel` imports no integration. + another integration; stateful sharing goes through the versioned ABI. 4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or generated. 5. Trace counts are per-impression (no double counting), and orphaned-slot recovery is covered by a non-vacuous test. -6. The only TSJS-owned global is `window.tsjs`; no expando properties on GPT - slots, GPT functions, or `pbjs`; the dead `__tsRenderGeneration` / - `__tsRenderBid` writes are gone. -7. The synchronous bundle is no larger than today's (target: smaller), and the - reference-page time-from-bids-script-to-first-`display()` does not regress. +6. The only TSJS-owned global is `window.tsjs` (public globals only inside + their announced compatibility window); no expandos on GPT slots, GPT + functions, or `pbjs`. +7. Per-bundle raw/gzip/Brotli sizes are at or below the checked-in baseline + within stated tolerance, and the percentile-based + bids-script-to-first-`display()` assertion does not regress; server-side + per-request bundle concatenation/hashing is precomputed. 8. No existing warning is lost: every issue-surfacing condition logs at `warn` - or above in the console, with the same reason code the beacon carries. -9. TypeScript and the dev toolchain are on latest stable (with the new - strictness flags enabled), `prebid.js`'s npm pin matches the documented - deployed bundle version, and the monthly review policy is in CI docs. + or above with the same reason code the beacon carries. +9. TypeScript and the dev toolchain are on latest stable with the new + strictness flags; `prebid.js`'s npm pin matches the documented deployed + bundle version; the monthly review policy is in CI docs. +10. Rolling-deploy cache tests pass: a `?v=A` request never caches bytes other + than `A`. -## 12. Open questions +## 13. Open questions 1. Is a mediator configured in the affected production deployment? (Decides whether A1 is the primary cause or a latent one.) -2. What share of live APS demand is `tagtype: "script"`? (Decides how urgent - the `allow_script_creatives` enablement guidance is.) -3. Should the direct-render fallback (6.3) ever become default-on for - publishers without GAM line items for `hb_bidder=aps`? +2. What share of live APS demand is `tagtype: "script"`? +3. Should `client_render_fallback` ever become default-on for publishers + without GAM line items for `hb_bidder=aps`? 4. Is PR #997 the intended restoration of the lost #922 attribution core, or should the original be re-merged? -5. Beacon endpoint naming and retention: `/_ts/client-events` vs folding into - the existing telemetry namespace. +5. Does any deployment serve descriptors for an origin whose config disables + APS (decides whether the renderer route becomes config-independent, 6.6)? +6. Datasource naming/retention for client events, and whether Axum/Cloudflare/ + Spin get real sinks or keep accept-count-drop. From 196f0b788d9503bc301197c446a59cf767c851b1 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:59:44 -0700 Subject: [PATCH 003/194] Revise design spec for the second review round Settles the correlation gap by minting the trace server-side for the initial navigation and echoing client-minted traces through the page-bids request, adds the per-attempt acknowledgement-nonce protocol so render_accepted is an authenticated kernel-observed event, renames observations to what the browser can actually see and terminates APS at render_accepted, splits nurl and burl into independent idempotent transitions with a declared billing policy, restricts fallback to an attributed terminal empty GPT event via a per-slot request-cycle protocol, moves the content hash into the asset pathname with retained artifacts and 410-on-unknown, reorders the migration so asset identity and the kernel ABI precede the beacon, drops the deal-priority rule the bid model cannot express, replaces accept_sizes with request-what- you-accept, minimizes the beacon payload with id kinds and a signed diagnostic capability, makes the renderer document route unconditional and versioned with a two-stage ack, bounds SafeFrame traversal to a parent-chain walk, fixes the tsjs.que and TypeScript-5.9 facts, and names the concrete numbers the review asked for. --- ...s-render-fix-and-tsjs-resilience-design.md | 1081 +++++++++-------- 1 file changed, 556 insertions(+), 525 deletions(-) diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index 6415d36d8..144b7ca36 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1,16 +1,17 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 2 — reworked after design review (review verdict: - request changes). The five architectural contracts the review required are - settled in section 4. +- **Status:** revision 3 — reworked after the second design review round. - **Date:** 2026-08-04 - **Baseline:** `rc/july` @ `541298695` — the full merged state (everything merged from `main` plus every rc-only merge), not just the delta pending against `main`. - **Inputs:** three code audits performed against this baseline (APS end-to-end - trace, TSJS architecture audit, GPT integration map); design review of - revision 1; open issues #926, #941, #944, #962, #964, #977, #983, #989, - #993; open PR #997. + trace, TSJS architecture audit, GPT integration map); design reviews of + revisions 1 and 2; open issues #926, #941, #944, #962, #964, #977, #983, + #989, #993; open PR #997. +- **Terminology:** the per-refresh counter is `refresh_gen` everywhere in this + document (revision 2 used `render_gen` in some places; that name is + retired). --- @@ -56,8 +57,6 @@ SPA `/_ts/page-bids` re-auction, (d) direct `/auction` via `tsjs.requestAds`. Only flow (d) — the demo path nobody runs in production — can render an APS descriptor without GAM's cooperation. -The failure points, ranked by likelihood and blast radius: - ### 2.1 Admission: APS bids are eliminated before they can win | # | Failure | Where | @@ -105,253 +104,291 @@ deciding the winner. The audit's key facts: 1. **The bundle's handoff and initial-load code is dead in production.** The bootstrap installs its wrappers first and sets the same sentinels the bundle checks; ~200 lines of the TypeScript the test suite exercises most heavily - never run on a real page. A fix landed only in `gpt/index.ts` has no - production effect. + never run on a real page. 2. **The slot handoff can alias a publisher's new div to a GPT slot bound to a dead element** — and the orphan-recovery watcher built to repair exactly that was **lost in the #922 merge** (`0dc9b19a9` resolved `gpt/index.ts` to the rc - side). `updateRender` (one impression, one row) now has no production - caller, `__tsRenderGeneration` / `__tsRenderBid` are dead writes, and every + side). `updateRender` now has no production caller, + `__tsRenderGeneration` / `__tsRenderBid` are dead writes, and every bridge-served impression double-counts in the trace. Open PR #997 appears to - be the reworked replacement; restoring this is a correctness prerequisite, - not a refactor. -3. **TS refreshes never pass `changeCorrelator: false`**, so every TS-driven - refresh starts a new GAM page-view correlator — silently changing roadblock, - competitive-exclusion, and frequency-capping behavior. + be the reworked replacement. +3. **TS refreshes never pass `changeCorrelator: false`**, silently changing + roadblock, competitive-exclusion, and frequency-capping behavior. 4. **`enableSingleRequest()` is called blind** after the publisher's own - `enableServices()` has almost always run (post-#945 deferral), so SRA intent - is asserted but not real — and on pages where TS wins the race, it forces - SRA onto publishers who chose otherwise. -5. Responsive resolution is a DOM-element-selection ladder (not GPT size - mapping); ambiguity silently skips the slot for the whole pass. -6. Three independent wrappers on `pubads().refresh` (bootstrap, bundle, prebid) - coordinate via window-global booleans that async wrappers observe already - reset. + `enableServices()` has almost always run. +5. Responsive resolution is a DOM-element-selection ladder; ambiguity silently + skips the slot for the whole pass. +6. Three independent wrappers on `pubads().refresh` coordinate via + window-global booleans that async wrappers observe already reset. +7. **GPT refresh is asynchronous and offers no request-cancellation + primitive** (`gpt/index.ts:1080`): once a refresh is issued, a response can + arrive arbitrarily late. Any fallback design must treat an issued GPT + request as uncancelable. --- -## 4. Design gates — the five contracts settled before implementation - -The design review identified five architectural contracts every later section -depends on. This revision settles them; changing any of these later reopens the -review. +## 4. Design gates — the five contracts -### G1 — Trace identity and event envelope +### G1 — Trace identity and correlation **The client-visible auction id must never be ingested.** It is EC-derived by -construction (`publisher.rs:3237`: `ts-{ec_id}` when an EC exists), and server -telemetry already uses a deliberately unrelated fresh UUID -(`telemetry.rs:94-97`), so a join on it is both a privacy violation and -impossible. - -Contract: - -- A **`trace_id`** is minted client-side per navigation: 128-bit CSPRNG - (`crypto.getRandomValues`), hex-encoded, held in `PageSession`, never - persisted, never derived from any identity. -- Every event carries the envelope `{trace_id, nav_gen, render_gen, seq}`. - `seq` is a per-trace monotonic counter (ordering + deduplication); - `nav_gen`/`render_gen` scope events to a navigation and a refresh cycle, so a - batch that spans SPA navigations remains attributable per event, not per - batch. -- The **server** stamps the same `trace_id` into its own telemetry rows by - reading it from the beacon, never the reverse: server auction rows keep their - independent telemetry UUID, and correlation happens only where the client - reported a `trace_id` alongside the events it observed. Failures with no - winning bid therefore still have a trace: the trace is client-born, not - auction-born. -- **Sampling is sticky per trace** (decided once at trace mint, recorded in the - envelope), never per event — a sampled trace is complete or absent. +construction (`publisher.rs:3237`: `ts-{ec_id}` when an EC exists). Server +telemetry uses an independent fresh UUID (`telemetry.rs:94-97`) — and, decisive +for the design: **initial-HTML auction telemetry is emitted before page +JavaScript exists** (`telemetry.rs:148`, `publisher.rs:2452`), so a +client-minted trace can never retroactively join it. + +Contract — correlation is minted by whoever acts first: + +- **Initial navigation (`nav_gen = 0`):** the **server** mints a + `trace_id` — 128-bit CSPRNG, hex (`^[0-9a-f]{32}$`), derived from nothing — + per HTML response, writes it into that response's auction telemetry rows at + emit time, and injects it into the page as a `tsjs` boot field alongside the + sampling decision. The client's `NavigationSession` adopts it. It is a new + value, never `AuctionRequest.id`. +- **SPA navigations (`nav_gen > 0`):** the **client** mints the `trace_id` for + the navigation and sends it in the `/_ts/page-bids` request body; the server + records it contemporaneously in that auction's telemetry rows. No + retroactive joining anywhere. +- **Envelope:** every event carries + `{trace_id, sampled, nav_gen, refresh_gen, seq}` — per event, not per batch, + so a transport batch may span navigations without ambiguity. `seq` is a + per-trace monotonic counter for ordering and deduplication. +- **Sampling** is decided once per trace (server-decided for `nav_gen 0`, + client-decided from the injected rate for later navigations) and recorded in + the envelope; a sampled trace is complete or absent. ### G2 — Render identity: `hb_adid`, the APS token, and the PBS Cache UUID -**The PBS Cache contract stays untouched.** Today `hb_adid` deliberately -prefers the Prebid Cache UUID (`publisher.rs:3355`), and both the emitted cache -coordinates and the bridge's cache fetch assume `?uuid=` -(`publisher.rs:3450`, `gpt/index.ts:1700`) — that is the Prebid Universal -Creative's documented contract. Revision 1's "token for every SSAT bid" would -have broken every cache-backed render and is withdrawn. +**The PBS Cache contract stays untouched.** `hb_adid` deliberately prefers the +Prebid Cache UUID (`publisher.rs:3355`), and both the emitted cache coordinates +and the bridge's cache fetch assume `?uuid=` (`publisher.rs:3450`, +`gpt/index.ts:1700`). Contract: -- Bids with a cache id: `hb_adid` = cache UUID, exactly as today. -- Bids with markup and no cache id: `hb_adid` = existing fallback chain - (`ad_id`, then bid id), exactly as today. +- Bids with a cache id: `hb_adid` = cache UUID, exactly as today. Bids with + markup and no cache id: existing fallback chain, exactly as today. - **Renderer-only bids (APS): `hb_adid` = a server-minted render token**, - format `^[a-z0-9]{12}$` (12 chars exactly), CSPRNG-generated with collision - retry within the auction, unique across slots and auctions, one-time - consumption in the bridge registry, TTL-bounded. Twelve characters sit well - inside GAM's documented 40-character targeting-value limit. -- The client-side Prebid adapter path keeps Prebid's generated `adId` (that is - Prebid's own contract); both paths register into one bridge registry keyed by - whichever id that path will observe. + format `^[a-z0-9]{12}$` (12 chars exactly, 36¹² ≈ 4.7 × 10¹⁸ values), + CSPRNG-generated. Collision handling is honest about its scope: retry on + collision **within the minting auction** (the only scope the server can + check without storage); cross-auction uniqueness is **probabilistic**, with + the birthday bound documented (at 10⁶ live tokens the collision probability + is ~10⁻⁷) and made harmless by scoping: the client registry keys tokens per + `(trace_id, nav_gen)`, so a cross-page collision cannot cross wires. +- Token lifecycle: TTL **15 minutes** from mint, one-time consumption in the + bridge registry, invalidated by navigation. +- The client-side Prebid adapter path keeps Prebid's generated `adId`; both + paths register into one bridge registry keyed by whichever id that path + observes. - Regression tests: non-APS cache-backed bids keep byte-identical `hb_adid` - and cache coordinates; the token property test asserts `^[a-z0-9]{12}$`, - uniqueness, one-time consumption, and TTL expiry. + and cache coordinates. ### G3 — Runtime ABI: how code shares state under the IIFE build Every entry point is built as a self-contained IIFE with dynamic imports inlined (`build-all.mjs:46`), and the server concatenates already-closed IIFEs -(`bundle.rs:23`). **A module `import` therefore never shares state across -bundles** — an `aps/index.ts` alone would just mint another private copy. This -is already a live defect beyond APS: `core/context.ts:11` holds a private -context-provider `Map` while `permutive/index.ts:102` registers into its own -independently bundled copy. +(`bundle.rs:23`) — a module `import` never shares state across bundles. Live +proof: `core/context.ts:11` holds a private context-provider `Map` while +`permutive/index.ts:102` registers into its own copy. Contract — **a versioned registration ABI on `window.tsjs._internal`**: - The kernel ships **only** in `tsjs-core` (always first in the concatenated unified bundle) and publishes `tsjs._internal = { abi: 1, registry }` exactly once, guarded by a window-level sentinel. -- All **stateful** services (slot registry, render state machine, APS renderer - registry, context providers, event bus, beacon queue) exist once, owned by - the kernel, and are reached **only** through - `tsjs._internal.registry.get(name, minVersion)` at call time — never through - imported module state. Pure stateless helpers may still be imported and - inlined freely. -- Deferred bundles (prebid) and later scripts interact through the same ABI; - `abi` majors are checked at lookup and a mismatch is a logged, telemetered - refusal, not a silent no-op. -- The alternative (single module graph / shared chunks emitted by the build and - loaded as real modules) is recorded as the long-term option; the ABI is - chosen now because it works under today's concatenation and deferred - loading without changing the delivery pipeline. -- This gate precedes and unblocks: the event bus, the slot registry, the - install registry, `PageSession`, the messaging service, and fixes the - context-provider split as its first proof. - -### G4 — Exactly-once render state machine, and honest success semantics - -**Absence of a bridge request is not proof GAM failed** — GAM may legitimately -serve a different, higher-priority creative without ever invoking the TS -bridge. And **`renderer-ready` means Amazon's runner script loaded, not that an -ad painted** (`aps.rs:105`); the current direct renderer returns `true` -immediately while the real outcome settles asynchronously for up to ten -seconds (`render.ts:318`). - -Contract: - -- One render state machine per placement instance, keyed by - `(trace_id, nav_gen, slot, refresh_gen, render_token)`, with exactly-once - terminal transition. States: - `targeting_set → bridge_claimed | gam_filled_other | fallback_running → -render_accepted → render_confirmed | render_failed(reason)`. -- **The fallback never replaces a nonempty GAM render.** Any bridge claim or - nonempty `slotRenderEnded` cancels a pending fallback; an **empty** - `slotRenderEnded` may trigger it; navigation, refresh, handoff, and slot - destruction invalidate stale attempts. -- The renderer API becomes awaitable with cancellation and an explicit - terminal reason; no fire-and-forget `true`. -- Event taxonomy replaces the single `render_ok`: - - `bridge_response_sent` — the bridge handed a payload to the PUC; - - `render_accepted` — the renderer document loaded Amazon's runner - (today's "ready"); - - `render_confirmed` — observed evidence of a paint (nonempty - `slotRenderEnded` for GAM fills; for the sandboxed renderer, iframe - load + non-collapsed geometry where observable). -- **Billing/win callbacks fire no earlier than `render_accepted`, and - `render_confirmed` is the only state reported as success.** Where APS - provides no paint acknowledgement, the design says so plainly: telemetry can - distinguish "runner loaded" from "nothing loaded," but **cannot distinguish - a painted ad from a blank one** inside the opaque frame — rows terminate at - `render_accepted` and are labeled as such, not upgraded. +- **Construction ownership:** the kernel constructs and registers the core + service instances (event bus, beacon queue, session objects, slot registry, + render state machine) during its own boot; integrations construct only + integration-scoped services and register them during their `install()`. +- All **stateful** services are reached only through + `tsjs._internal.registry.get(name, minVersion)` at call time. Pure stateless + helpers may be imported and inlined freely. +- `abi` majors are checked at lookup; a mismatch is a logged, telemetered + refusal, not a silent no-op. Mixed-version delivery (old deferred bundle, + new core) is a tested scenario, not an accident. +- The single-module-graph build is recorded as the successor option behind the + same ABI surface. + +### G4 — Render lifecycle: cycles, acknowledgements, and honest states + +Four sub-contracts, each fixing a hole the reviews identified. + +**G4a — GPT request-cycle protocol.** `slotRenderEnded` identifies a slot, not +a request, and the bridge currently reads live `window.tsjs.bids` +(`gpt/index.ts:1606`) while the generation snapshots are dead writes +(`gpt/index.ts:1085`). Contract: every TS-issued `display()`/`refresh()` opens +a **cycle** `(slot, refresh_gen)` pushed onto a per-slot pending-cycle queue; +`slotRequested` confirms it; GPT fires slot events in order per slot, so +`slotRenderEnded` is attributed to the oldest confirmed pending cycle for the +slot. Each bridge token and each render attempt binds to exactly one cycle. If +attribution is ambiguous (overlapping cycles the queue cannot separate, or an +event with no pending cycle), the state machine for that slot **fails closed**: +no fallback, `render_fail{cycle_unattributable}`, console warning. + +**G4b — Acknowledgement path.** Today the renderer document posts ready only +to its immediate parent (`aps.rs:105`); in the PUC path that parent is the +nested renderer frame, which resolves a local promise (`render.ts:423`) the +top-level kernel cannot observe — and callbacks currently fire right after the +bridge posts its response (`gpt/index.ts:1572`, `:1620`). Contract: the bridge +response carries a **per-attempt CSPRNG acknowledgement nonce**; the dynamic +renderer posts versioned `render_accepted` / `render_failed{reason}` messages +**to the kernel** (top window), carrying the nonce; the kernel validates +source ownership, nonce, token, `nav_gen`, and `refresh_gen` before any state +transition or callback. This protocol is pinned by tests for all three flows: +SSAT, client-Prebid, and nested SafeFrame. + +**G4c — Honest observation names.** The browser cannot see inside an opaque +APS frame, and the iframe's geometry is assigned by our own renderer — it +proves nothing about content. A nonempty `slotRenderEnded` proves GAM +delivered a creative container, not that the nested runner painted. The state +machine therefore records observations under accurate names — +`gam_nonempty`, `gam_empty`, `renderer_document_loaded`, `runner_loaded`, +`runner_failed` — and **APS attempts terminate at `render_accepted`** +(= authenticated `runner_loaded` ack) unless Amazon provides a real completion +acknowledgement. `render_confirmed` exists only for paths with same-origin +observable content (inline adm frames TS itself writes); it is never derived +from geometry or from PUC container delivery. Tests: accepted-but-blank, and +nonempty-`slotRenderEnded`-before-bridge-claim. + +**G4d — `nurl`/`burl` are separate business events.** OpenRTB 2.6 +distinguishes them: `nurl` is the win notice (implies neither delivery nor +billability); `burl` is the billable-event notice under exchange policy. +Today both fire together (`gpt/index.ts:459`). Contract — independent, +idempotent transitions: + +1. winner selection → fire `nurl`; +2. `render_accepted` (authenticated) → fire `burl` — this is the **declared + commercial policy** for APS given no paint acknowledgement exists, recorded + here explicitly rather than implied; +3. terminal failure after acceptance → no un-firing; the row is labeled + `billed_then_failed` so the policy's cost is measurable. + +**G4e — Fallback trigger.** GPT offers no cancellation (section 3.7), so a +timeout can race a late fill that arrives after a fallback has rendered and +billed. Contract: the opt-in direct fallback +(`[auction].client_render_fallback = "renderer"`) renders **only after an +explicit terminal empty event for the bound cycle** (`gam_empty` from G4a +attribution). A timeout emits diagnostics (`render_fail{bridge_claim_timeout}`) +and **never renders**. For publisher-owned (adopted) slots, fallback is +disabled entirely. TS-owned-slot timeout rendering is admitted only as a +possible future extension that must first destroy the slot to retire the +request, and is out of scope here. ### G5 — Deployment contracts - **Config rollback:** every new auction/config field is default-valued and - **omitted from serialization at its default** (`AuctionConfig` denies - unknown fields, `auction_config_types.rs:7`), so blobs written by a new - binary remain readable by the previous one unless an operator opts in. -- **Ingest routing:** the beacon route exists in **all four adapters** - (Fastly, Axum, Cloudflare, Spin) as an early, EC-free, filter-free route. - Only Fastly has a real telemetry sink today; the no-sink behavior elsewhere - is "accept, count, drop" — defined, not accidental. -- **Storage:** a new dedicated datasource for client events (the existing - auction datasource cannot hold the shape without migration); schema, - retention, and sampling documented with the datasource definition. -- **Asset content-addressing is fixed, not assumed.** Script URLs embed a - content hash but the handler ignores the query and serves current registry - bytes from the path alone (`tsjs.rs:3`, `publisher.rs:294`) — during a - rolling deploy an old `?v=A` request can receive bytes `B` and cache them - under `A`. The handler must validate the requested hash and serve the - matching immutable artifact (or answer with the current hash's redirect); - rolling-deploy cache tests pin this. -- **Phase ordering** follows section 9; every phase ships behind a feature - flag with canary thresholds and rollback criteria. + omitted from serialization at its default (`auction_config_types.rs:7` + denies unknown fields), so blobs written by a new binary remain readable by + the previous one unless an operator opts in. +- **Asset identity is path-based and retained.** A query hash the handler + ignores (`tsjs.rs:3`, `publisher.rs:294`) is not content addressing, and + redirecting an old hash to current bytes just executes new code under old + HTML, bootstrap flags, and ABI expectations. Contract: the content hash + moves into the **pathname** (`/static/tsjs//.js`); the server + serves through a hash→bytes manifest that **retains prior artifacts** beyond + the maximum HTML cache lifetime plus the deferred-load window (retention + floor: 7 days); `Cache-Control: immutable` only on exact hash matches; + unknown hashes answer `410 Gone` with `no-store` — never a redirect to + different bytes. Precomputed concatenations are keyed by the **ordered + module-ID vector** (order affects side effects), not the set. +- **Ingest routing:** the beacon route exists in all four adapters (Fastly, + Axum, Cloudflare, Spin) as an early, EC-free, filter-free route. Only Fastly + has a real sink today; the others accept-count-drop by explicit contract. +- **Storage:** a new dedicated datasource named `ts_client_events`; retention + **30 days**; production sampling default **10%** (operator-tunable); + schema versioned with the event enum. +- **Phase ordering** is section 8's; each phase ships behind a feature flag + with the named canary thresholds and rollback criteria in section 8. --- ## 5. Workstream 1 — Observability -### 5.1 Client disposition beacon +### 5.1 Event payload — minimized by design -A kernel module batches disposition events to `POST /_ts/client-events`: +High-cardinality identifiers stay out of the beacon: no raw `hb_adid`, no raw +Prebid `adId`, no free-form slot strings. ``` -{ v: 1, trace: {trace_id, sampled}, events: [ - { seq, nav_gen, render_gen, t: "bid_received", slot, bidder, source } - { seq, nav_gen, render_gen, t: "targeting_set", slot, hbAdid } - { seq, nav_gen, render_gen, t: "bridge_request", slot, adId, matched } - { seq, nav_gen, render_gen, t: "bridge_response_sent", slot, source } - { seq, nav_gen, render_gen, t: "render_attempt", slot, source } - { seq, nav_gen, render_gen, t: "render_accepted", slot, source } - { seq, nav_gen, render_gen, t: "render_confirmed", slot, source } - { seq, nav_gen, render_gen, t: "render_fail", slot, source, reason } +{ v: 1, events: [ + { trace_id, sampled, nav_gen, refresh_gen, seq, + t: "bid_received" | "targeting_set" | "bridge_request" | + "bridge_response_sent" | "render_attempt" | "render_accepted" | + "render_confirmed" | "render_fail", + slot, // configured slot id if in the injected slot set, else "s" + id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none" + matched, // bridge_request only: token/id equality result + source, // "renderer" | "adm" | "pbs-cache" | "gam" + reason } // render_fail only: closed enum below ] } ``` -- **Transport:** `fetch(..., {keepalive: true, credentials: "omit"})` is the - primary transport, because `sendBeacon` always sends credentials and its - `true` return only means "queued," not "received." `sendBeacon` remains the - documented last-resort fallback on `pagehide` where keepalive is - unavailable, and the ingest handler ignores credentials in all cases. -- **Batching:** flush on `visibilitychange`/`pagehide` and every 5 s; each - event self-describes its navigation via the envelope, so batches spanning - SPA navigations stay attributable. -- **Reasons are a closed enum**, structurally serialized (never interpolated - into log lines): `renderer_no_ready`, `descriptor_invalid`, - `bridge_id_mismatch`, `gam_empty`, `gam_filled_other`, `no_render_source`, - `slot_unresolved`, `gpt_absent`, `pbjs_absent`, `bundle_partial`, - `fallback_cancelled`, `timeout`. `renderer_no_ready` (not - `renderer_endpoint_404`) is deliberate: the opaque iframe cannot read an - HTTP status, so the observable fact is "no ready message before timeout." - -### 5.2 Ingest contract - -- Registered in all four adapters before auth/EC/filters (G5); same-origin - enforced via `Origin`/`Sec-Fetch-Site` checks; hard caps on body bytes, - event count, and string lengths **enforced before parsing or logging**; - malformed rows dropped and counted; per-IP rate limiting; responds - `204 Cache-Control: no-store`; touches no KV and mints no identity. -- Server logs a bounded, structured summary per batch; the Fastly sink writes - to the new datasource (G5); other adapters count-and-drop until a sink - exists. - -### 5.3 Two modes, honestly separated - -A sampled, best-effort beacon **cannot** guarantee a complete diagnosis from -one page load — so the design stops claiming it: - -- **Production telemetry:** sticky-sampled traces, SLO "a delivery failure - mode occurring on ≥ N% of impressions is visible in the datasource within - one hour." -- **Diagnostic mode:** explicitly enabled (tester cookie / query flag), - unsampled, full event stream plus console mirroring — this is the "one page - load tells you which failure fired" tool. - -### 5.4 Server-side drop-reason surfacing - -- Emit a bounded structured summary **whenever any bid is dropped** (not only - when zero survive): per-slot reason counts, capped. -- Add `drop_reasons` to auction telemetry rows. -- The initial-HTML `ts-debug` comment gains the drop summary; `/_ts/page-bids` - returns JSON and **cannot carry an HTML comment**, so it gains a gated - structured `debug` field instead, enabled by the same tester gate. -- Startup validation warnings: APS enabled while `allow_script_creatives = -false` ("script-type APS demand will be dropped"); any direct provider - configured alongside a mediator without the merge strategy of 6.1 ("provider - bids cannot win as configured"). +- Every stored string is either a member of a server-known allowlist (slot ids + from the injected config, enum members) or a bounded ordinal — nothing free + .form is persisted. +- Reason enum: `renderer_document_no_load`, `runner_no_load`, `runner_failed`, + `descriptor_invalid`, `bridge_id_mismatch`, `cycle_unattributable`, + `bridge_claim_timeout`, `gam_empty`, `no_render_source`, `slot_unresolved`, + `gpt_absent`, `pbjs_absent`, `bundle_partial`, `fallback_cancelled`, + `abi_mismatch`. (`renderer_no_ready` from revision 2 is split by the G4b/6.6 + protocol into document-load vs runner-load failures.) + +### 5.2 Transport + +`fetch(..., {keepalive: true, credentials: "omit"})` primary; `sendBeacon` as +the documented last-resort `pagehide` fallback (credentialed by platform +design, and its `true` means queued, not received — the handler ignores +credentials either way). Flush on `visibilitychange`/`pagehide` and every 5 s. + +### 5.3 Ingest wire contract (numeric, complete) + +- Route: `POST /_ts/client-events`, registered in all four adapters before + auth/EC/filters. Content type: `application/json` only (no + `Content-Encoding`; compressed bodies rejected). Responds + `204 Cache-Control: no-store`. +- Limits enforced **before parse or log**: body ≤ **16 KiB**; ≤ **64** events + per batch; any string field ≤ **64** chars; `trace_id` must match + `^[0-9a-f]{32}$`; `nav_gen`/`refresh_gen`/`seq` are integers in + `[0, 2³¹)`. Violation → `204` (accepted-and-dropped) + abuse counter; the + endpoint never echoes input. +- Same-origin enforcement: `Sec-Fetch-Site: same-origin` when present; + otherwise `Origin` must match the serving host; **absent both → reject** + (drop-and-count). All strings are structurally serialized (never + interpolated into log lines). +- Client IP for rate limiting is derived per adapter from its documented + trusted source (Fastly: the platform client IP; Axum: configured trusted + proxy header; Cloudflare/Spin: platform equivalents). Rate limiting uses the + platform limiter where one exists (Fastly); portable adapters ship a + best-effort in-memory limiter and the policy is **fail-open with an abuse + counter** (dropping telemetry must never block ad delivery). +- **Diagnostic mode is a server-injected capability, not a query flag.** The + tester gate (cookie) is evaluated server-side at HTML render; the page + receives a short-lived signed capability token (HMAC over + `trace_id + expiry`, ≤ 15 minutes) which the client echoes in the batch. + The ingest handler verifies the signature — this works with + `credentials: "omit"` because the capability travels in the payload, and a + public query flag alone can never switch a session to unsampled. + +### 5.4 Two modes, honestly separated + +- **Production telemetry:** sticky-sampled (default 10%), SLO: **a delivery + failure mode affecting ≥ 1% of impressions is visible in `ts_client_events` + within one hour**. +- **Diagnostic mode:** capability-gated, unsampled, full event stream plus + console mirroring — the "one page load names the failing reason" tool. + +### 5.5 Server-side drop-reason surfacing + +- Emit a bounded structured summary **whenever any bid is dropped** (per-slot + reason counts, capped), not only when zero survive. +- Add `drop_reasons` to auction telemetry rows; add the drop summary to the + initial-HTML `ts-debug` comment; `/_ts/page-bids` (JSON) gains a gated + structured `debug` field under the same tester gate. +- Startup validation warnings: APS enabled while + `allow_script_creatives = false`; any direct provider configured alongside a + mediator without the 6.1 merge strategy. --- @@ -359,124 +396,128 @@ false` ("script-type APS demand will be dropped"); any direct provider ### 6.1 Mediation: opt-in merge, `mediator_only` stays the default -The configured contract today is explicit — the mediator is the final -decision-maker (`auction_config_types.rs:48`) — and changing that default -silently would be an economic breaking change with a rollback hazard. So: - - `[auction].winner_selection = "mediator_only"` (default, today's behavior, - now explicit) or `"merge_highest_cpm"` (opt-in). The field is omitted from - serialized blobs at its default (G5). -- `merge_highest_cpm` semantics, defined up front: comparison in the auction's - decoded CPM currency; slot floors apply to both populations; a bid present - in both (a provider bid the mediator also returned) counts once, by - provenance `mediator`; deals outrank open bids regardless of CPM; ties break - to the mediator; a mediator timeout degrades to direct-provider selection - (today's short-circuit) and is reported as such. -- **One candidate-selection helper serves both mediation lifecycles** — the - ordinary/page-bids path (`orchestrator.rs:412-431`) and the initial-SSAT - split dispatch/collect path (`orchestrator.rs:1320`) — with tests for each. -- A **selection report** (`winner_source`, `mediator_superseded` counts) is - emitted separately from the delivery-conversion drop reasons, so "lost the - merge" is never conflated with "failed to serialize." - -### 6.2 Dimensions: exact membership stays; flexibility is operator-declared - -Revision 1's containment rule ("never larger on either axis") would still -reject its own motivating example (a 300×600 answer on a 300×250/728×90 slot) -while admitting pathological 1×1 sizes — withdrawn. Instead: - -- Exact size membership (`aps.rs:657-668`) remains the default; it is - consistent with discrete GAM slot formats. -- An operator may declare flexibility per slot: - `accept_sizes = [[300, 600], …]` (an explicit allow-list of additional - creative sizes, with documentation that GAM line items must accept them) — - no inference, no aspect heuristics in v1. Admitted alternate sizes set - `hb_size` targeting (set and cleared with the other `hb_*` keys) and the - served size is reported in the selection report. + now explicit) or `"merge_highest_cpm"` (opt-in); omitted from serialized + blobs at the default (G5). +- `merge_highest_cpm` semantics: comparison in decoded CPM; **currency + mismatch is a rejection** (the mismatched bid is dropped with a selection + reason; no conversion in v1); slot floors apply to both populations; ties + break to the mediator; a mediator timeout degrades to direct-provider + selection and is reported. +- **Deduplication key:** the server constructs the mediator's input, so it + records provenance at forwarding time — `(provider_name, upstream_bid_id)` + per candidate — and carries a provenance map keyed by the id it sent. + A mediator bid whose id maps back to a forwarded candidate counts once, as + provenance `mediator`. A mediator bid whose id was **transformed beyond the + map** is treated as distinct mediator demand (documented limitation). +- **Deal priority is out of scope for v1.** The internal `Bid` + (`types.rs:231`) carries no deal identity; inventing a priority rule the + model cannot express would be fiction. Extending the bid model with + `deal_id`/deal type and a deal-first rule is recorded as follow-up work; + until then deals compete by CPM like everything else and the limitation is + documented in the config reference. +- One candidate-selection helper serves **both** mediation lifecycles + (ordinary/page-bids, `orchestrator.rs:412-431`; initial-SSAT split + dispatch/collect, `orchestrator.rs:1320`), with tests for each. +- A **selection report** (`winner_source`, `mediator_superseded`, + `currency_rejected`, dedup hits) is emitted separately from + delivery-conversion drop reasons. + +### 6.2 Dimensions: the contract is "request what you accept" + +Revision 2's operator `accept_sizes` allow-list is withdrawn on the review's +sharper observation: if an alternate size is acceptable, it belongs in the +slot's **requested formats** — APS should be asked for it. Accepting an +unrequested response size would conceal an upstream protocol violation. + +- Exact size membership (`aps.rs:657-668`) remains the admission rule, + unchanged. +- The fix is configuration plus visibility: the drop summary (5.5) names the + rejected size per slot (`invalid_dimensions{300x600}`), so an operator sees + exactly which format to add to the slot's `formats` if they want that + demand. Documentation gains a "sizing your slots for APS" section. +- No `hb_size` key, no admission relaxation, no new config. ### 6.3 Script creatives Keep the secure default (`allow_script_creatives = false`) but make the -consequence loud (5.4) and document the enablement path for TAM-heavy -publishers. The renderer sandbox already isolates script tag types; this is a -policy toggle, not new machinery. +consequence loud (5.5) and document the enablement path for TAM-heavy +publishers. ### 6.4 Render identity -Implemented exactly as G2: cache UUID untouched, render token only for -renderer-only bids, one registry, token property tests -(`^[a-z0-9]{12}$`, CSPRNG, collision retry, TTL, one-time consumption, -cross-slot/auction uniqueness), and a non-APS cache-path regression test. +Implemented exactly as G2 (token scope, format, TTL, per-navigation registry +keying, one-time consumption, cache-path regression tests). ### 6.5 Fallback rendering -Implemented exactly as G4: opt-in -(`[auction].client_render_fallback = "renderer"`), driven by the render state -machine, triggered only by an **empty** GAM render or a bridge-claim timeout -with no fill evidence, cancelled by any bridge claim or nonempty render, -invalidated by navigation/refresh/destruction, and reported with the G4 event -taxonomy. The direct renderer is converted to an awaitable API first; the -fallback lands only after that conversion. - -### 6.6 Renderer endpoint - -- Document the topology: within one deployment the renderer route and the APS - provider share the same config gate (`aps.rs:1224`, `:1244`), so "server - emits descriptors but lacks the route" is a **cross-deployment or stale-CDN - problem**, and a 401 most plausibly comes from broad `[[handlers]]` auth - patterns matching `/integrations/*` before route dispatch. -- Therefore: validate at startup that no configured auth handler pattern - covers `/integrations/aps/renderer`; make the static renderer document - config-independent **iff** the deployment serves multiple origins from one - config (recorded as an open question with the operator); version the - renderer document and define its cache headers. -- The client reports `renderer_no_ready` (5.1) — no status probe is added, - because a probe would violate the no-new-critical-path-request budget. -- CSP audit (C6) unchanged from revision 1: extend `APS_RENDERER_CSP` only - with sources observed in real Amazon traffic, each justified in a comment - and covered by the browser spec. +Implemented exactly as G4e: renders only on an attributed terminal +`gam_empty`; timeouts are diagnostics-only; disabled for adopted slots. The +direct renderer is converted to an awaitable API with cancellation and a +terminal reason first; the fallback lands only after that conversion. + +### 6.6 Renderer endpoint — unconditional, versioned, observable + +Topology is resolved now rather than left conditional: + +- **The static renderer document route registers unconditionally in every + adapter.** It contains no configuration, no secrets, and validates its input + client-side; serving it cannot leak anything, and conditional registration + is exactly what created the silent cross-deployment failure class. (The APS + _provider_ stays config-gated; only the static document is unconditional.) +- The document is **versioned in its path** + (`/integrations/aps/renderer/v1`) and served `Cache-Control: no-store`; + descriptor compatibility across N/N−1 is guaranteed by the outer-tolerant + validation of 6.7. The client pins the version it targets. +- Startup validation fails loudly if any configured auth handler pattern + covers `/integrations/aps/renderer`. +- **Two-stage acknowledgement (with G4b):** the document first posts an + authenticated `document_loaded` (proving route + auth + CSP allowed the + document itself), then the separate runner-load result. This splits the old + blind timeout into `renderer_document_no_load` (route/auth/stale-CDN/network) + vs `runner_no_load` / `runner_failed` (Amazon script or CSP) — distinct + signals, as the success criteria require. +- Server-side: route status/version counters (requests, unknown-version, + auth-blocked) join the telemetry rows. +- **CSP changes ship report-only first** (`Content-Security-Policy-Report-Only` + canary with a bounded report endpoint), then enforce; each added source is + justified in a comment and covered by the browser spec. Tests cover broad + auth patterns, stale versions, and CSP failures on all adapters. ### 6.7 One descriptor schema — structural generation, semantic validators kept -- The wire truth is the **tagged enum** `BidRenderer` (the `type: "aps"` - discriminator lives there, not on `ApsRendererV1` — `types.rs:188-211`), so - the generated schema is the full tagged envelope. -- Generation lives in a **separate wire-schema crate** (or a host-side - xtask) — it cannot live in `trusted-server-js`'s build because core already - depends on that crate (`Cargo.toml:45`) and the reverse edge would be a - cycle. Generated TS artifacts are checked in; CI fails on staleness. -- Generation covers **structure only** (fields, types, discriminator, - version). The semantic security checks stay hand-written on both sides: - URL/origin policy, canonical base64, length bounds, the exact one-bid - envelope projection, cross-field equality. **Unknown-field tolerance applies - only to the outer versioned descriptor; the decoded AAX envelope remains an - exact projection** so `adm`, notification URLs, or sibling fields can never - slip through unexamined. -- A shared corpus — positive cases plus an adversarial set (extra fields, - wrong versions, oversized payloads, URL smuggling, non-canonical base64) — - runs through the Rust validator, the TS validator, and the inline renderer - document in CI. +- The wire truth is the tagged enum `BidRenderer` (discriminator lives there, + not on `ApsRendererV1` — `types.rs:188-211`); the generated schema is the + full tagged envelope. +- Generation lives in a **separate wire-schema crate** (or host-side xtask) — + core already depends on `trusted-server-js` (`Cargo.toml:45`), so the + reverse edge would be a cycle. Generated TS artifacts are checked in; CI + fails on staleness. +- Generation covers structure only; the semantic security checks stay + hand-written on both sides (URL/origin policy, canonical base64, length + bounds, the exact one-bid envelope projection, cross-field equality). + **Unknown-field tolerance applies only to the outer versioned descriptor; + the decoded AAX envelope remains an exact projection.** +- A shared positive + adversarial corpus runs through the Rust validator, the + TS validator, and the inline renderer document in CI. ### 6.8 Bridge hardening -- **Ownership proof stays source-first.** The bridge continues to resolve the - message source to a slot and only then compares that slot's expected id - (`gpt/index.ts:1599` order) — a MessageChannel port plus a token is not - ownership proof, because an inbound port has no pre-established slot - identity and the token is visible in `window.tsjs.bids`. For SafeFrame, - source resolution is extended to walk nested browsing contexts via - `window.frames` containment checks rather than DOM `querySelectorAll` only; - where the source is unresolvable the bridge refuses (with - `bridge_id_mismatch`) instead of trusting the token. -- Adversarial tests are part of the contract: wrong-slot tokens, nested - foreign frames, replayed and duplicated messages, stolen tokens, and - previous-navigation tokens — not only the positive SafeFrame case. -- Blanket top-of-listener hygiene: parse and ownership-check before any - branch logic; new branches inherit protection. -- Delete the dead duplicate renderer branch (C5); move its dedup and debug log - into the live branch. -- Renderer branches emit the same trace records as the adm and cache branches, - under G4's event taxonomy and billing rules (C7). +- **Ownership proof stays source-first**, and the SafeFrame extension is + bounded: the kernel maintains a map of known slot-root `WindowProxy` objects + (the iframes GPT created under each slot element); on a message, it walks + the **sender's own parent chain** (`event.source.parent`, …) up to depth + **5**, looking for a known root — it never enumerates or recursively scans + an attacker-controllable frame tree. Unresolvable source → refuse with + `bridge_id_mismatch`. +- Adversarial tests: wrong-slot tokens, nested foreign frames, replayed and + duplicated messages, stolen tokens, previous-navigation tokens, plus the + positive SafeFrame case. +- Blanket top-of-listener hygiene: parse and ownership-check before branch + logic. +- Delete the dead duplicate renderer branch (C5); renderer branches emit the + same trace records as adm/cache branches under G4's taxonomy and the G4d + `nurl`/`burl` split (C7). --- @@ -485,125 +526,113 @@ fallback lands only after that conversion. ### 7.1 Layering ``` -kernel/ boot, config, command queue, event bus, log, telemetry beacon +kernel/ boot, config, queue, event bus, log, beacon, sessions adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access services/ slots (registry+handoff), auction client, render engine, consent integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services) ``` -Rules, enforced by an eslint boundary rule in CI (`import/no-restricted-paths`): - -- `kernel` imports nothing above it; `adapters` import kernel only; `services` - import kernel + adapters; `integrations` import kernel + services, **never - each other**. -- Stateful services are reached through the G3 registration ABI, never through - imported module state; stateless helpers may be imported and inlined. -- This dissolves today's inversions: `core/auction.ts` and `core/request.ts` - importing `integrations/aps/render`, `gpt` and `prebid` importing `aps`, and - `prebid` owning the GPT refresh wrapper. +Boundary lint in CI (`import/no-restricted-paths`): `kernel` imports nothing +above it; `adapters` import kernel only; `services` import kernel + adapters; +`integrations` import kernel + services, never each other. Stateful services +via the G3 ABI only. ### 7.2 Adapters: explicit absence, without giving up on late loaders -Every external global is wrapped once with a state machine -`present | pending | timed_out`, a queue for `pending`, and per-operation -bounds. `timed_out` is **not terminal**: publishers legitimately lazy-load -GPT, Prebid, and CMPs, so a later arrival transitions the adapter to `present` -and drains what is still valid; individual queued operations carry their own -timeouts and expire with a disposition reason rather than the adapter -permanently disabling itself. +`present | pending | timed_out` per external global; `timed_out` is +non-terminal (late GPT/pbjs/CMP arrival transitions to `present` and drains +what is still valid); individual queued operations carry their own timeouts +and expire with a disposition reason. ### 7.3 Slot registry service -One registry owns all slot knowledge: publisher-defined vs TS-defined, -adoption, handoff claims, responsive element resolution, refresh generation, -targeting-key history — keyed by `WeakMap` plus a -div-id index, owned by the kernel via the G3 ABI. Expando properties on live -GPT objects are eliminated. The GPT integration feeds events in and executes -registry decisions; the prebid refresh handler consumes the same registry. +One registry owns slot knowledge (publisher- vs TS-defined, adoption, handoff +claims, responsive resolution, pending request cycles per G4a, targeting-key +history), keyed by `WeakMap` plus a div-id index, +kernel-owned via the ABI. Expandos on live GPT objects are eliminated. ### 7.4 Global namespace policy — with a compatibility window -One owned global, `window.tsjs`, public API versioned, coordination state -under `tsjs._internal` (G3). But the migration must not break published -contracts: - -- **Inventory first:** every current global is classified public - (`globalThis.tscreative`, `tsCreativeConfig` — documented, settable - pre-load) or private (`__tsjs_*` flags, expandos, sentinels). -- **Public globals get a bounded dual-read/write window:** old and new names - both work for a stated deprecation period, with pre-init compatibility tests - (config set before the bundle loads must keep working); removal is its own - later, announced change. -- Private globals migrate immediately: per-slot expandos - (`__tsRenderGeneration`, `__tsRenderBid` — dead writes today, delete now) - into `SlotRecord`; function-object sentinels into a kernel-held `WeakSet`; - boot flags into the `window.tsjs = window.tsjs || {cmd: []}` pattern. +- One owned global, `window.tsjs`; public API versioned; coordination state + under `tsjs._internal` (G3). **The public queue keeps its existing name: + `window.tsjs.que`** (`types.ts:259`, drained at `core/index.ts:25`) — + revision 2's `cmd` was an error; renaming a public surface silently would + violate this very section. +- Inventory first: every current global classified public + (`globalThis.tscreative`, `tsCreativeConfig`, `tsjs.que`) or private + (`__tsjs_*` flags, expandos, sentinels). +- **Public globals: dual-read/write for a bounded window — two release + cycles, minimum 60 days — ending only after an adoption gate: beacon-observed + old-name usage below 0.1% of traces for 14 consecutive days.** Pre-init + compatibility tests pin that config set before the bundle loads keeps + working. +- Private globals migrate immediately: dead expando writes deleted now; slot + state into `SlotRecord`; function sentinels into a kernel `WeakSet`; boot + flags into `tsjs` boot fields. +- `requestAds` keeps its void signature; failure surfacing arrives as a **new + versioned async API** (`tsjs.requestAdsAsync(...): Promise`) + rather than changing the existing contract. ### 7.5 Messaging module -All `postMessage` traffic goes through one module: versioned envelopes, message -name constants (today `'Prebid Request'` appears as a bare literal at six -sites, and the APS handshake exists in three hand-synced copies), source -validation per 6.8, and one audit point. - -### 7.6 Plugin lifecycle - -`install()` replaces import-time side effects, with the semantics the review -demanded: - -- `tsjs.definePlugin(id, version, install, dispose)` registers; the kernel - resolves install requests against registrations, so **a deferred bundle that - registers after `tsjs.install([...])` was requested is installed on - arrival** (pending-install queue), bounded by a missing-module timeout that - emits `bundle_partial`. +All `postMessage` traffic through one module: versioned envelopes, message +name constants, the G4b acknowledgement nonces, source validation per 6.8, one +audit point. + +### 7.6 Plugin lifecycle and session model + +- **Activation:** Rust owns integration selection today and continues to — the + server injects a **versioned install manifest** (enabled plugin ids + + expected versions, in injection order) into the pre-core `tsjs.que`. The + kernel executes the manifest on boot; nobody else calls install in + production (the API remains callable for tests). +- `tsjs.definePlugin(id, version, install, dispose)`: synchronous `install` + by default; a plugin may return a promise, but anything `adInit` depends on + (gpt, prebid shim registration) must complete synchronously and is listed as + such in the manifest. Late registration after the manifest requested the id + installs on arrival (pending-install queue) bounded by a missing-module + timeout emitting `bundle_partial`. A stale async completion (arriving after + its `RuntimeSession` was disposed) is discarded. Duplicate `(id, version)` + is a no-op; a different version for a registered id: first-wins + loud + telemetry. Disposal runs in reverse install order. +- **Session model, split as the review required:** + - `RuntimeSession` (page lifetime): bridge listener, history hook, pbjs + subscriptions, adapters, beacon queue. + - `NavigationSession` (per SPA navigation): `trace_id`, render attempts, + slot aliases, targeting history, navigation-scoped timers/observers. + - `RenderAttempt` (per G4a cycle): state machine instance, ack nonce. + Each owns an **enumerable** disposal inventory; navigation disposes + `NavigationSession` children only. - Per-plugin exception isolation: a plugin that throws during install is - quarantined and reported; it cannot halt the bundle (today `didomi` can - throw during module evaluation and stop everything after it). -- Duplicate registration of the same `(id, version)` is a no-op; a different - version for a registered id follows a declared policy (first-wins + loud - telemetry). -- A `PageSession` object owns an **enumerable** set of listeners, timers, - observers, and slot records — registered at creation, disposed on - navigation. A `WeakMap` alone cannot dispose anything; the owned-set is the - disposal inventory. -- Error policy: no empty `catch` — every catch handles, logs with context, or - emits a disposition reason. The auction fetch gets a timeout + - `AbortController`, and `requestAds` surfaces failure to its caller. -- **Console logging is retained, not replaced.** The beacon is additive: every - issue-surfacing condition keeps (or gains) a `log.warn` debuggable from an - open DevTools console. Existing warnings survive verbatim or strengthened; - failure paths currently at `debug` (invisible at the default `warn` level — - the creative `dynamic_src_guard` and click-guard rejection paths) are - promoted to `warn` when they indicate a delivery or security-relevant - failure; every `render_fail` / dependency-timeout disposition emits a paired - `warn` carrying the same reason code, so console and beacon tell one story. + quarantined and reported; it cannot halt the bundle. +- Error policy: no empty `catch`; every catch handles, logs with context, or + emits a disposition reason. The auction fetch gets timeout + + `AbortController`. +- **Console logging is retained, not replaced.** Every issue-surfacing + condition keeps (or gains) a `log.warn` debuggable from DevTools; existing + warnings survive verbatim or strengthened; `debug`-level failure paths that + indicate delivery or security-relevant failures are promoted to `warn`; + every `render_fail`/dependency-timeout disposition emits a paired `warn` + with the same reason code. ### 7.7 The bootstrap problem -`gpt_bootstrap.js` duplicates ~400 lines of the hardest logic (handoff, -initial-load detection, hydration deferral) in hand-written ES5, always wins -the sentinel race, and has one live divergence (its simpler `adInit` can run -first and permanently suppress the bundle's `slotRenderEnded` listener). - -Target: shrink the inline bootstrap to a queue-and-flags stub (create -`googletag.cmd` interception points, record early publisher calls, expose the -enable flag), with the bundle replaying recorded calls on install. This is -**not a pure move** — replay changes observable ordering — so it ships behind -its own flag with the browser specs extended to cover replay timing, and the -no-bundle fallback ("ads still render if the bundle fails," pinned by -`gpt.rs:1174-1179`) is **generated from the same TypeScript source** at build -time, never hand-maintained. +Target: shrink the inline `gpt_bootstrap.js` to a queue-and-flags stub with +the bundle replaying recorded calls on install. This is **not a pure move** — +replay changes observable ordering — so it ships behind its own flag with +browser specs extended to cover replay timing, and the no-bundle fallback +("ads still render if the bundle fails", pinned by `gpt.rs:1174-1179`) is +**generated from the same TypeScript source** at build time. ### 7.8 GPT correctness fixes carried with the restructure - Restore the #922 orphan-slot recovery and `updateRender` enrichment (verify against open PR #997; land whichever is canonical). -- Pass `changeCorrelator: false` on TS-initiated refreshes; correlator - behavior becomes a documented, configurable decision. -- `enableSingleRequest()` only when GPT services are not already enabled; - otherwise adopt the publisher's mode and record it. -- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` in - addition to its console warning. +- `changeCorrelator: false` on TS-initiated refreshes; correlator behavior + becomes a documented, configurable decision. +- `enableSingleRequest()` only when GPT services are not already enabled. +- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` + alongside its console warning. ### 7.9 Decomposition targets @@ -611,48 +640,38 @@ time, never hand-maintained. | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | | `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | | `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (moves onto slot registry), eids, diagnostics | -| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory the other six integrations already use | +| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | | `core/trace.ts` (record model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | | `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split into public API vs internal coordination state | ### 7.10 Performance -Client-side, the design is a net speedup with enforced budgets: - -- **Smaller synchronous bundle:** script-guard consolidation, single APS - module (via the ABI), dead-code deletion, trace-overlay extraction. -- **Fewer repeated DOM walks:** slot resolution once per navigation in the - registry. -- **Bounded waits instead of blind ones:** the 10 s silent renderer timeout - and forever-queued GPT cases become short, telemetered timeouts. -- **Budgets in CI, precisely specified:** per-bundle byte sizes measured raw, - gzip, and Brotli for an exact named module set, compared against a - checked-in baseline artifact with a stated tolerance; the browser-spec - timing assertion (bids-script-to-first-`display()`) runs N times and gates - on a percentile, not a single sample. - -Server-side (new in this revision): each injected page currently concatenates -and hashes the full immediate bundle, and the asset request concatenates it -again (`bundle.rs:51`). Precompute bundle bytes + hash per registry module set -(they change only at deploy/config time), and benchmark server CPU/heap before -and after. +Client-side speedups with enforced budgets: smaller synchronous bundle +(script-guard consolidation, single APS module via the ABI, dead-code +deletion, trace-overlay extraction); slot resolution once per navigation; +bounded telemetered waits. Budgets, concretely: per-bundle raw/gzip/Brotli +bytes for the exact ordered module vector of the reference config, compared +to a checked-in baseline with **+5% tolerance**; the +bids-script-to-first-`display()` browser assertion runs **20 iterations** and +gates on **p90**. Server-side: precompute bundle bytes + hash per ordered +module vector (deploy/config-time), benchmark server CPU/heap before/after. ### 7.11 Toolchain and dependency currency -- **TypeScript to latest stable** (library pins `^5.5.4` while vite 7 / - vitest 4 / typescript-eslint 8 are current), adopting +- **TypeScript:** the manifest floor is `^5.5.4` but the lockfile already + resolves **5.9.3** (`package-lock.json`), so the code compiles on 5.9 today. + Action: raise the manifest floor to the resolved 5.9 line (making the floor + honest), then evaluate the next major as its own gated PR; adopt `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, - `verbatimModuleSyntax` — these directly serve killing the `as unknown as` - escapes and the wrong `global.d.ts` declaration. + `verbatimModuleSyntax`. - **Dev toolchain to latest stable** (eslint + plugins, prettier, jsdom, `@playwright/test`, `@types/node` aligned to the pinned Node), each bump its - own mechanical CI-gated PR with changelog review — this library - monkeypatches `fetch`, `sendBeacon`, and DOM prototypes, so jsdom and - Playwright behavior changes are real risks. -- **`prebid.js` is excluded from casual bumps:** the runtime Prebid is the - external R2 bundle locked by manifest hash and SRI; upgrading it is its own - coordinated deploy. The npm pin and the deployed bundle version stay - documented together so tests exercise the version production runs. + own mechanical CI-gated PR with changelog review — this library monkeypatches + `fetch`, `sendBeacon`, and DOM prototypes, so jsdom/Playwright behavior + changes are real risks. +- **`prebid.js` is excluded from casual bumps** (runtime Prebid is the + manifest-locked external R2 bundle); the npm pin and deployed bundle version + stay documented together. - **Standing policy:** monthly dependency review; no migration phase starts more than one minor behind latest stable. @@ -660,33 +679,39 @@ and after. ## 8. Migration plan -Reordered so every phase's prerequisites precede it; each phase ships behind a -feature flag with canary thresholds and rollback criteria. - -- **Phase 0 — Contracts and toolchain.** Settle G1–G5 in code-adjacent docs; - toolchain upgrades (7.11); the trace envelope + beacon + four-adapter ingest - (accept-count-drop outside Fastly) behind a flag; server drop-reason - surfacing (5.4); reason codes on today's silent returns; delete the dead - expando writes. No runtime behavior change for pages with the flag off. -- **Phase 1 — Runtime ABI + APS admission/identity.** G3 kernel registry in - `tsjs-core` (context-provider fix is the proof); wire-schema crate + shared - corpus (6.7); mediation selection helper + opt-in merge (6.1); render token - for renderer-only bids (6.4); renderer-endpoint startup validation (6.6); - bridge hardening minus fallback (6.8). APS renders after this phase wherever - GAM line items and configuration permit — the no-GAM fallback is explicitly - Phase 2. -- **Phase 2 — Render state machine + GPT correctness.** Minimal `SlotRecord` - core (just enough for the state machine keys; full registry lands in - Phase 3); awaitable renderer conversion; the exactly-once fallback (6.5, - G4); restore #922/#997 attribution and orphan recovery; correlator and SRA - fixes (7.8). -- **Phase 3 — Structure.** Full layering + boundary lint, plugin lifecycle + - `PageSession` (7.6), adapters (7.2), full slot registry (7.3), messaging - module (7.5), namespace migration with its compatibility window (7.4), - asset content-addressing fix + server bundle precompute (G5, 7.10). -- **Phase 4 — Decomposition.** File splits (7.9), script-guard consolidation, - bootstrap shrink behind its own flag with replay-timing specs (7.7), and - the end of the public-global compatibility window. +Ordered so every phase's prerequisites precede it (the review's required +order). Every phase ships behind a feature flag; shared canary criteria: no +increase in `render_fail` rate beyond **+0.5% absolute** on canary traffic +over 24 h, no new console errors in the browser-spec run, rollback = flag off +(phases 0–2 are additive; later phases keep dual paths until their gate). + +- **Phase 0 — Asset identity, contracts, toolchain.** Path-based immutable + asset identity + manifest retention + rolling-deploy tests (G5) — this lands + **before** anything makes ABI compatibility load-bearing. Toolchain floors + (7.11). Contracts G1–G5 recorded as code-adjacent docs. Delete the dead + expando writes. Server drop-reason surfacing (5.5) — server-only, no client + dependency. +- **Phase 1 — Kernel ABI and sessions.** Minimal kernel in `tsjs-core` + publishing the versioned registry (G3); `RuntimeSession` / + `NavigationSession` scopes (7.6); context-provider fix as the ABI proof; + install manifest injection. +- **Phase 2 — Trace and beacon.** Server correlation nonce for `nav_gen 0` + + page-bids trace echo (G1); beacon service on the Phase-1 kernel; ingest + route in all four adapters (5.3); diagnostic capability; `ts_client_events` + datasource. +- **Phase 3 — APS delivery.** Wire-schema crate + corpus (6.7); mediation + helper + opt-in merge (6.1); render token (6.4); unconditional versioned + renderer route + two-stage ack (6.6, G4b); bridge hardening (6.8); GPT + request-cycle protocol + render state machine + awaitable renderer + + `nurl`/`burl` split (G4a–G4d); the opt-in fallback (G4e); restore #922/#997 + attribution; correlator and SRA fixes (7.8). APS renders after this phase + wherever GAM line items and configuration permit. +- **Phase 4 — Structure.** Full layering + boundary lint, plugin lifecycle + completion, adapters, full slot registry, messaging module, namespace + migration window (7.4), server bundle precompute (7.10). +- **Phase 5 — Decomposition.** File splits (7.9), script-guard consolidation, + bootstrap shrink behind its own flag with replay-timing specs (7.7), end of + the public-global compatibility window (gated per 7.4). --- @@ -696,92 +721,98 @@ Blocking CI is hermetic (the deterministic PUC/message harness); a separate staged smoke suite covers real GAM line items and is release-gating, not PR-gating. -| Area | Must cover | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Mediation | both lifecycles (ordinary + SSAT split dispatch/collect); ties, floors, deals, duplicate demand; `mediator_only` default preserved; rollback blob round-trip (omitted defaults) | -| Cache identity | non-APS cache-backed bids: byte-identical `hb_adid` + cache coordinates (regression); PUC `?uuid=` fetch path | -| Render token | `^[a-z0-9]{12}$`; CSPRNG source; collision retry; TTL; one-time consumption; cross-slot/auction uniqueness | -| Fallback | races vs bridge claim; nonempty-GAM protection; repeated refresh; SPA navigation cancellation; slot destruction; exactly-once terminal transition | -| Render semantics | no billing after runner-load failure; `render_accepted` vs `render_confirmed` labeling; opaque-frame honesty (no painted/blank claim) | -| Bridge security | wrong-slot tokens; nested foreign frames; replayed + duplicate messages; stolen tokens; previous-navigation tokens; SafeFrame positive case | -| Beacon | trace joins; ordering/dedup via `(trace_id, nav_gen, seq)`; batching across navigations; loss tolerance; ingest abuse (oversize, malformed, cross-origin); all-adapter routing | -| Schema | generated-artifact staleness; adversarial corpus through Rust + TS + inline validator; outer-tolerance vs exact AAX projection | -| Runtime ABI | one kernel instance under concatenation; deferred registration after install request; per-plugin failure isolation; abi-version mismatch refusal | -| Lifecycle | late-loaded GPT/pbjs (`timed_out → present`); `PageSession` disposal inventory; pre-init `tsCreativeConfig` compatibility; dual-name global window | -| Delivery | immutable-cache behavior across a simulated rolling deploy; deterministic raw/gzip/Brotli bundle budgets vs baseline artifact | -| Observability | drop-reason summary on any partial drop; page-bids structured `debug` field gating; diagnostic mode unsampled completeness | +| Area | Must cover | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Mediation | both lifecycles; ties, floors, currency rejection; duplicate demand via provenance map; transformed mediator ids; `mediator_only` default preserved; rollback blob round-trip (omitted defaults) | +| Cache identity | non-APS cache-backed bids byte-identical (`hb_adid` + coordinates); PUC `?uuid=` fetch path | +| Render token | `^[a-z0-9]{12}$`; CSPRNG source; in-auction collision retry; 15-min TTL; one-time consumption; per-`(trace, nav_gen)` registry scoping | +| Request cycles | pending-cycle attribution; overlapping refreshes; late `slotRenderEnded`; nonempty-before-bridge-claim; `cycle_unattributable` fail-closed | +| Ack protocol | nonce validation (source, token, nav_gen, refresh_gen); SSAT + client-Prebid + nested SafeFrame flows; stale/replayed acks | +| Render semantics | no `burl` before authenticated `render_accepted`; `nurl` at selection independent of delivery; `billed_then_failed` labeling; accepted-but-blank; no `render_confirmed` from geometry or PUC container | +| Fallback | renders only on attributed `gam_empty`; timeout is diagnostics-only; adopted-slot fallback disabled; SPA cancellation; slot destruction; exactly-once terminal transition | +| Bridge security | wrong-slot tokens; nested foreign frames; replay + duplicates; stolen tokens; previous-navigation tokens; bounded parent-chain walk (depth 5); SafeFrame positive case | +| Beacon | initial-nav server nonce join; page-bids trace echo; per-event envelope across navigation-spanning batches; `(trace_id, nav_gen, seq)` dedup; ingest abuse (oversize, malformed, cross-origin, absent Origin); capability sig | +| Schema | generated-artifact staleness; adversarial corpus through Rust + TS + inline validator; outer-tolerance vs exact AAX projection | +| Runtime ABI | one kernel under concatenation; deferred registration after manifest request; per-plugin failure isolation; **mixed-version delivery (old deferred bundle + new core)**; abi-mismatch refusal | +| Lifecycle | late-loaded GPT/pbjs (`timed_out → present`); RuntimeSession vs NavigationSession disposal inventories; stale async install completion; pre-init `tsCreativeConfig` and `tsjs.que` compatibility; dual-name global window | +| Delivery | rolling-deploy simulation (old HTML + retained old assets); unknown hash → 410 `no-store`; immutable only on exact match; ordered-module-vector keying; deterministic raw/gzip/Brotli budgets vs baseline | +| Renderer endpoint | route present in all four adapters; auth-pattern startup failure; stale version behavior; CSP report-only canary; `document_loaded` vs runner-result split | +| Adapter parity | ingest + renderer routes + drop-reason surfacing behave equivalently on Fastly/Viceroy vs Axum vs Cloudflare vs Spin | +| Policy | script-creative startup warning; `invalid_dimensions{WxH}` drop naming; page-bids `debug` field gating; diagnostic-mode unsampled completeness | --- ## 10. Alternatives considered 1. **Keep patching APS point-failures without telemetry.** Rejected: three - consecutive correct fixes have not produced ads; without disposition data - the next fix is another guess. -2. **Direct-render APS always (skip GAM/PUC).** Simplest render path, but - changes GAM reporting/pacing semantics unilaterally; kept as the opt-in, - state-machine-guarded fallback instead. + consecutive correct fixes have not produced ads. +2. **Direct-render APS always (skip GAM/PUC).** Changes GAM reporting/pacing + semantics unilaterally; kept as the opt-in, `gam_empty`-gated fallback. 3. **Single module graph / shared chunks instead of the registration ABI.** - Cleaner long-term, but changes the delivery pipeline (chunk loading) now; - recorded as the successor option behind the same ABI surface. -4. **Full library rewrite in one branch.** Rejected: the browser-spec safety - net is thin in exactly the areas being changed. + Cleaner long-term; changes the delivery pipeline now; successor option + behind the same ABI surface. +4. **Full library rewrite in one branch.** Rejected: thin browser-spec safety + net in exactly the changing areas. 5. **Drop the ES5 bootstrap entirely.** Loses the pinned "ads render if the - bundle fails" guarantee; the generated-fallback approach keeps it without - dual maintenance. + bundle fails" guarantee; generated fallback keeps it. +6. **Timeout-triggered fallback rendering.** Rejected (G4e): GPT requests + cannot be cancelled, so timeout rendering races late fills; only an + attributed terminal empty event may trigger rendering. ## 11. Risks -- **Merge strategy misconfiguration** changes auction economics; mitigated by - keeping `mediator_only` the default, the selection report, and omitted - serialization at defaults. -- **Beacon abuse/volume:** bounded by pre-parse caps, origin checks, rate - limits, sticky sampling, and closed enums. -- **ABI freeze risk:** `tsjs._internal.registry` becomes load-bearing; - versioned from day one, majors checked at lookup. -- **Bootstrap replay** changes observable ordering; own flag, replay-timing - specs, staged rollout. -- **Schema generation** adds a build step; checked-in artifacts + staleness CI. +- **Merge strategy misconfiguration:** mitigated by `mediator_only` default, + the selection report, omitted-default serialization. +- **Beacon abuse/volume:** pre-parse caps, origin checks, per-adapter rate + limiting, sticky sampling, closed enums, capability-gated diagnostics. +- **ABI freeze risk:** versioned from day one; mixed-version delivery tested. +- **Ack protocol adds a message round-trip before `burl`:** bounded by the + existing renderer timeout; the `billed_then_failed` label measures the + policy. +- **Bootstrap replay:** own flag, replay-timing specs, staged rollout. +- **Schema generation:** checked-in artifacts + staleness CI. ## 12. Success criteria 1. APS creatives render on a reference page in each configured flow (SSAT, - Prebid adapter, page-bids), proven hermetically in CI and by the staged - smoke suite against real GAM line items. -2. Every failure point in section 2 maps to a distinct observable signal, and - **diagnostic mode** yields the failing reason from one page load; production - telemetry meets the stated SLO (5.3). -3. `eslint` boundary rules pass with zero exceptions; no integration imports - another integration; stateful sharing goes through the versioned ABI. + Prebid adapter, page-bids), hermetically in CI and via the staged smoke + suite. +2. Every failure point in section 2 maps to a distinct observable signal; + diagnostic mode names the failing reason from one page load; production + telemetry meets the 5.4 SLO (≥ 1% failure modes visible within one hour). +3. Boundary lint passes with zero exceptions; stateful sharing only via the + versioned ABI; mixed-version delivery behaves as specified. 4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or generated. -5. Trace counts are per-impression (no double counting), and orphaned-slot - recovery is covered by a non-vacuous test. +5. Trace counts are per-impression; orphaned-slot recovery has a non-vacuous + test; `refresh_gen` attribution follows the G4a cycle protocol. 6. The only TSJS-owned global is `window.tsjs` (public globals only inside - their announced compatibility window); no expandos on GPT slots, GPT - functions, or `pbjs`. -7. Per-bundle raw/gzip/Brotli sizes are at or below the checked-in baseline - within stated tolerance, and the percentile-based - bids-script-to-first-`display()` assertion does not regress; server-side - per-request bundle concatenation/hashing is precomputed. -8. No existing warning is lost: every issue-surfacing condition logs at `warn` - or above with the same reason code the beacon carries. -9. TypeScript and the dev toolchain are on latest stable with the new - strictness flags; `prebid.js`'s npm pin matches the documented deployed - bundle version; the monthly review policy is in CI docs. -10. Rolling-deploy cache tests pass: a `?v=A` request never caches bytes other - than `A`. + their announced window, which closes per the 7.4 adoption gate); no + expandos on GPT slots, GPT functions, or `pbjs`. +7. Bundle budgets (raw/gzip/Brotli, +5% tolerance vs baseline) and the p90 + 20-run timing assertion hold; server-side per-request concatenation is + precomputed. +8. No existing warning is lost; every issue-surfacing condition logs at `warn` + or above with the beacon's reason code. +9. TypeScript floor matches the resolved 5.9 line with the strictness flags + on; `prebid.js` npm pin matches the documented deployed bundle; monthly + review policy in CI docs. +10. Rolling-deploy tests: old HTML always receives its exact old assets during + the retention window; unknown hashes 410 with `no-store`; immutable + caching only on exact matches. +11. `nurl` and `burl` fire on their distinct G4d transitions, idempotently, + and never before their triggering state. ## 13. Open questions -1. Is a mediator configured in the affected production deployment? (Decides - whether A1 is the primary cause or a latent one.) +1. Is a mediator configured in the affected production deployment? 2. What share of live APS demand is `tagtype: "script"`? 3. Should `client_render_fallback` ever become default-on for publishers without GAM line items for `hb_bidder=aps`? 4. Is PR #997 the intended restoration of the lost #922 attribution core, or should the original be re-merged? -5. Does any deployment serve descriptors for an origin whose config disables - APS (decides whether the renderer route becomes config-independent, 6.6)? -6. Datasource naming/retention for client events, and whether Axum/Cloudflare/ - Spin get real sinks or keep accept-count-drop. +5. Do Axum/Cloudflare/Spin get real client-event sinks, or keep + accept-count-drop? (Datasource `ts_client_events`, 30-day retention, and + 10% default sampling are proposed values pending operator sign-off.) +6. Does Amazon expose any creative-completion acknowledgement we could adopt + to move APS beyond `render_accepted` (G4c)? From d92f801567054d288dd9597baebab48863facee7 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:43:41 -0700 Subject: [PATCH 004/194] Revise design spec for the third review round Moves the SPA trace onto a validated X-TSJS-Trace-Id header because page-bids is a GET, adds the cache-privacy invariant for trace-bearing HTML, rebuilds cycle attribution without ordering assumptions (ts-vs- publisher classification, one outstanding TS cycle per slot, overlap fails closed, real-GAM release-gating test), scopes nurl/burl to bid paths that carry them with attempt-scoped idempotency and leaves the APS runner lifecycle untouched, removes render_confirmed after the sandboxed-srcdoc correction, pulls minimal messaging, the cycle registry, and unconditional GPT subscriptions forward so Phase 3 has its dependencies, defines mixed-version ABI verdicts (major/minMinor ranges, quarantine, first-wins among compatible), makes retained assets realizable via two-stage publishing to shared immutable storage with both rolling directions tested, groups beacon events per trace with per-trace capabilities and page-bids capability issuance, fails telemetry rate limiting closed with per-adapter trusted-address definitions, parameterizes the SLO and phase-specific gates, corrects the renderer /v1 to immutable caching with an aggregate-only counter claim and a fully specified CSP report route, allows fallback on attributed gam_empty regardless of slot ownership, and tightens the remaining bounded-schema, provenance, and performance-method edges. --- ...s-render-fix-and-tsjs-resilience-design.md | 1112 ++++++++--------- 1 file changed, 501 insertions(+), 611 deletions(-) diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index 144b7ca36..9c53fca6a 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1,17 +1,15 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 3 — reworked after the second design review round. +- **Status:** revision 4 — reworked after the third design review round. - **Date:** 2026-08-04 - **Baseline:** `rc/july` @ `541298695` — the full merged state (everything merged from `main` plus every rc-only merge), not just the delta pending against `main`. -- **Inputs:** three code audits performed against this baseline (APS end-to-end - trace, TSJS architecture audit, GPT integration map); design reviews of - revisions 1 and 2; open issues #926, #941, #944, #962, #964, #977, #983, - #989, #993; open PR #997. -- **Terminology:** the per-refresh counter is `refresh_gen` everywhere in this - document (revision 2 used `render_gen` in some places; that name is - retired). +- **Inputs:** three code audits performed against this baseline; design + reviews of revisions 1–3; open issues #926, #941, #944, #962, #964, #977, + #983, #989, #993; open PR #997. +- **Terminology:** the per-refresh counter is `refresh_gen` everywhere. The + event previously named `render_confirmed` is removed (see G4c). --- @@ -20,32 +18,29 @@ APS (Amazon Publisher Services) demand is fully integrated server-side — the edge server runs the APS OpenRTB auction, wins bids, and ships a typed renderer descriptor to the page — yet APS creatives still do not appear for real users. -Every previous fix (the `bid.meta` carrier so Prebid does not strip the -descriptor, the decoupled prebid shim, the `hb_adid` fallback to the OpenRTB bid -id) addressed a real defect, and APS still does not render. That pattern — serial -single-cause fixes that each survive review and still do not produce ads — is -itself the finding: the APS pipeline has **multiple independent failure points, -most of which fail silently**, and the client library has **no way to tell the -server (or the operator) which one fired**. +Every previous fix (the `bid.meta` carrier, the decoupled prebid shim, the +`hb_adid` fallback) addressed a real defect, and APS still does not render. +That pattern is itself the finding: the APS pipeline has **multiple independent +failure points, most of which fail silently**, and the client library has **no +way to tell the server (or the operator) which one fired**. At the same time, the TSJS client library has grown organically to 56 files / ~11,900 lines with two ~1,700-line monoliths, duplicated logic maintained by -hand in two languages, inverted layering, and roughly one hundred `catch` blocks -that discard failures. The APS outage and the library's shape are the same -problem seen from two sides: a delivery pipeline whose failure modes are -invisible and whose components cannot be reasoned about independently. +hand in two languages, inverted layering, and roughly one hundred `catch` +blocks that discard failures. The APS outage and the library's shape are the +same problem seen from two sides. This design covers both: (a) the specific fixes that make APS render, and (b) -the target architecture that makes TSJS a clean, resilient library so the next -integration does not reproduce this failure class. +the target architecture that makes TSJS a clean, resilient library. ### Non-goals -- No change to the APS OpenRTB endpoint contract or Amazon-side configuration. +- No change to the APS OpenRTB endpoint contract or Amazon-side configuration + (including its deliberate absence of `nurl`/`burl` — see G4d). - No rewrite of Prebid.js integration strategy (the decoupled shim stays). -- No behavior change for publishers whose pages work today. Where this design - must migrate a public surface (section 7.4), it does so behind a bounded - compatibility window, never by immediate removal. +- No behavior change for publishers whose pages work today. Public-surface + migrations happen behind a bounded compatibility window (7.4), never by + immediate removal. --- @@ -87,43 +82,38 @@ descriptor without GAM's cooperation. ### 2.4 Observability: the common factor -There is **zero client→server reporting**. Server telemetry marks `is_win=1` at -auction time and goes quiet; a bid that never painted is byte-identical to one -that painted perfectly. Client-side evidence (`window.tsjs.renders`, console -warnings, `data-ts-*` attributes) dies with the tab. +There is **zero client→server reporting**. Server telemetry marks `is_win=1` +at auction time and goes quiet; a bid that never painted is byte-identical to +one that painted perfectly. Client-side evidence dies with the tab. --- ## 3. The GPT reality this design must respect -The GPT integration is a **bootstrap-first hybrid**: the server injects a -495-line ES5 `gpt_bootstrap.js` inline in `` before the TSJS bundle, and -the two coordinate through shared monkeypatch sentinels, with document order -deciding the winner. The audit's key facts: - -1. **The bundle's handoff and initial-load code is dead in production.** The - bootstrap installs its wrappers first and sets the same sentinels the bundle - checks; ~200 lines of the TypeScript the test suite exercises most heavily - never run on a real page. -2. **The slot handoff can alias a publisher's new div to a GPT slot bound to a - dead element** — and the orphan-recovery watcher built to repair exactly that - was **lost in the #922 merge** (`0dc9b19a9` resolved `gpt/index.ts` to the rc - side). `updateRender` now has no production caller, - `__tsRenderGeneration` / `__tsRenderBid` are dead writes, and every - bridge-served impression double-counts in the trace. Open PR #997 appears to - be the reworked replacement. -3. **TS refreshes never pass `changeCorrelator: false`**, silently changing - roadblock, competitive-exclusion, and frequency-capping behavior. +1. **Bootstrap-first hybrid:** the server injects a 495-line ES5 + `gpt_bootstrap.js` before the bundle; shared monkeypatch sentinels mean the + bundle's handoff and initial-load code is dead in production. +2. **The #922 merge loss:** orphan-slot recovery and `updateRender` enrichment + are gone (`0dc9b19a9`); `__tsRenderGeneration`/`__tsRenderBid` are dead + writes; bridge-served impressions double-count. PR #997 appears to be the + reworked replacement. +3. **TS refreshes never pass `changeCorrelator: false`.** 4. **`enableSingleRequest()` is called blind** after the publisher's own `enableServices()` has almost always run. 5. Responsive resolution is a DOM-element-selection ladder; ambiguity silently - skips the slot for the whole pass. + skips the slot. 6. Three independent wrappers on `pubads().refresh` coordinate via - window-global booleans that async wrappers observe already reset. -7. **GPT refresh is asynchronous and offers no request-cancellation - primitive** (`gpt/index.ts:1080`): once a refresh is issued, a response can - arrive arbitrarily late. Any fallback design must treat an issued GPT - request as uncancelable. + window-global booleans. +7. **GPT offers no request cancellation** (`gpt/index.ts:1080`), and its event + contract identifies only the slot: Google documents no per-refresh + identifier and no completion-order guarantee for overlapping requests, and + `slotRenderEnded` means creative code was injected, not that its resources + loaded. Publisher code can refresh an adopted slot while a TS cycle is + pending. Any attribution scheme must survive all of that (G4a). +8. **The bundle's `slotRenderEnded` registration is gated behind + `!ts.servicesEnabled`** (`gpt/index.ts:1017`), so bootstrap-first or + services-enabled pages can miss the listener entirely — G4a requires + unconditional early subscription. --- @@ -131,264 +121,285 @@ deciding the winner. The audit's key facts: ### G1 — Trace identity and correlation -**The client-visible auction id must never be ingested.** It is EC-derived by -construction (`publisher.rs:3237`: `ts-{ec_id}` when an EC exists). Server -telemetry uses an independent fresh UUID (`telemetry.rs:94-97`) — and, decisive -for the design: **initial-HTML auction telemetry is emitted before page -JavaScript exists** (`telemetry.rs:148`, `publisher.rs:2452`), so a -client-minted trace can never retroactively join it. - -Contract — correlation is minted by whoever acts first: - -- **Initial navigation (`nav_gen = 0`):** the **server** mints a - `trace_id` — 128-bit CSPRNG, hex (`^[0-9a-f]{32}$`), derived from nothing — - per HTML response, writes it into that response's auction telemetry rows at - emit time, and injects it into the page as a `tsjs` boot field alongside the - sampling decision. The client's `NavigationSession` adopts it. It is a new - value, never `AuctionRequest.id`. -- **SPA navigations (`nav_gen > 0`):** the **client** mints the `trace_id` for - the navigation and sends it in the `/_ts/page-bids` request body; the server - records it contemporaneously in that auction's telemetry rows. No - retroactive joining anywhere. +**The client-visible auction id must never be ingested** (EC-derived: +`publisher.rs:3237`). **Initial-HTML auction telemetry is emitted before page +JavaScript exists** (`telemetry.rs:148`, `publisher.rs:2452`), so correlation +is minted by whoever acts first: + +- **Initial navigation (`nav_gen = 0`):** the server mints `trace_id` + (128-bit CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction + telemetry rows at emit time (a new nullable `trace_id` column on the + existing auction datasource; the independent telemetry `auction_id` UUID + remains and remains separate), and injects it into the page as a `tsjs` + boot field with the sampling decision and, when the tester gate is active, + the diagnostic capability (5.3). +- **Cache-privacy invariant:** a trace or capability is injected **only** into + responses that ran a per-request auction, and any trace-bearing HTML MUST be + shared-cache-ineligible: `Cache-Control: private, no-store` and no + validators that could revalidate a shared copy. This is enforced by + construction (the injection site is the auction-bearing render path) and by + test. A shared-cached page would have no per-visitor auction to correlate + anyway; the invariant makes that alignment explicit. +- **SPA navigations (`nav_gen > 0`):** `/_ts/page-bids` **stays GET** (it is + GET in `publisher.rs:3815` and the client issues GET at + `gpt/index.ts:1152`; a browser GET cannot carry a body). The client mints + the `trace_id` and sends it in a validated **`X-TSJS-Trace-Id`** request + header — the request already carries a non-simple TSJS header, so CORS + preflight behavior is unchanged. The server records it contemporaneously in + that auction's telemetry rows and the JSON response **echoes the accepted + trace id** and returns a capability bound to it when the tester gate is + active. - **Envelope:** every event carries - `{trace_id, sampled, nav_gen, refresh_gen, seq}` — per event, not per batch, - so a transport batch may span navigations without ambiguity. `seq` is a - per-trace monotonic counter for ordering and deduplication. -- **Sampling** is decided once per trace (server-decided for `nav_gen 0`, - client-decided from the injected rate for later navigations) and recorded in - the envelope; a sampled trace is complete or absent. + `{trace_id, sampled, nav_gen, refresh_gen, seq}`; `seq` is per-trace + monotonic. +- **Sampling is trace-sticky** (decided once per trace; server-decided for + `nav_gen 0`, client-decided from the injected rate afterwards). Transport is + best-effort, so a sampled trace may still arrive partial; partiality is + detectable via `seq` gaps and is not treated as a contract violation. ### G2 — Render identity: `hb_adid`, the APS token, and the PBS Cache UUID -**The PBS Cache contract stays untouched.** `hb_adid` deliberately prefers the -Prebid Cache UUID (`publisher.rs:3355`), and both the emitted cache coordinates -and the bridge's cache fetch assume `?uuid=` (`publisher.rs:3450`, -`gpt/index.ts:1700`). - -Contract: - -- Bids with a cache id: `hb_adid` = cache UUID, exactly as today. Bids with - markup and no cache id: existing fallback chain, exactly as today. -- **Renderer-only bids (APS): `hb_adid` = a server-minted render token**, - format `^[a-z0-9]{12}$` (12 chars exactly, 36¹² ≈ 4.7 × 10¹⁸ values), - CSPRNG-generated. Collision handling is honest about its scope: retry on - collision **within the minting auction** (the only scope the server can - check without storage); cross-auction uniqueness is **probabilistic**, with - the birthday bound documented (at 10⁶ live tokens the collision probability - is ~10⁻⁷) and made harmless by scoping: the client registry keys tokens per - `(trace_id, nav_gen)`, so a cross-page collision cannot cross wires. -- Token lifecycle: TTL **15 minutes** from mint, one-time consumption in the - bridge registry, invalidated by navigation. -- The client-side Prebid adapter path keeps Prebid's generated `adId`; both - paths register into one bridge registry keyed by whichever id that path - observes. -- Regression tests: non-APS cache-backed bids keep byte-identical `hb_adid` - and cache coordinates. +Unchanged from revision 3 (review-accepted): cache-backed bids keep the cache +UUID as `hb_adid` byte-for-byte; renderer-only bids get a server-minted token +`^[a-z0-9]{12}$` (CSPRNG; in-auction collision retry; cross-auction uniqueness +probabilistic with the birthday bound documented; harmless via per- +`(trace_id, nav_gen)` registry scoping); TTL 15 minutes; one-time consumption; +client-Prebid keeps Prebid's `adId`; non-APS cache-path regression tests. ### G3 — Runtime ABI: how code shares state under the IIFE build -Every entry point is built as a self-contained IIFE with dynamic imports -inlined (`build-all.mjs:46`), and the server concatenates already-closed IIFEs -(`bundle.rs:23`) — a module `import` never shares state across bundles. Live -proof: `core/context.ts:11` holds a private context-provider `Map` while -`permutive/index.ts:102` registers into its own copy. - -Contract — **a versioned registration ABI on `window.tsjs._internal`**: - -- The kernel ships **only** in `tsjs-core` (always first in the concatenated - unified bundle) and publishes `tsjs._internal = { abi: 1, registry }` exactly - once, guarded by a window-level sentinel. -- **Construction ownership:** the kernel constructs and registers the core - service instances (event bus, beacon queue, session objects, slot registry, - render state machine) during its own boot; integrations construct only - integration-scoped services and register them during their `install()`. -- All **stateful** services are reached only through - `tsjs._internal.registry.get(name, minVersion)` at call time. Pure stateless - helpers may be imported and inlined freely. -- `abi` majors are checked at lookup; a mismatch is a logged, telemetered - refusal, not a silent no-op. Mixed-version delivery (old deferred bundle, - new core) is a tested scenario, not an accident. -- The single-module-graph build is recorded as the successor option behind the - same ABI surface. - -### G4 — Render lifecycle: cycles, acknowledgements, and honest states - -Four sub-contracts, each fixing a hole the reviews identified. - -**G4a — GPT request-cycle protocol.** `slotRenderEnded` identifies a slot, not -a request, and the bridge currently reads live `window.tsjs.bids` -(`gpt/index.ts:1606`) while the generation snapshots are dead writes -(`gpt/index.ts:1085`). Contract: every TS-issued `display()`/`refresh()` opens -a **cycle** `(slot, refresh_gen)` pushed onto a per-slot pending-cycle queue; -`slotRequested` confirms it; GPT fires slot events in order per slot, so -`slotRenderEnded` is attributed to the oldest confirmed pending cycle for the -slot. Each bridge token and each render attempt binds to exactly one cycle. If -attribution is ambiguous (overlapping cycles the queue cannot separate, or an -event with no pending cycle), the state machine for that slot **fails closed**: -no fallback, `render_fail{cycle_unattributable}`, console warning. - -**G4b — Acknowledgement path.** Today the renderer document posts ready only -to its immediate parent (`aps.rs:105`); in the PUC path that parent is the -nested renderer frame, which resolves a local promise (`render.ts:423`) the -top-level kernel cannot observe — and callbacks currently fire right after the -bridge posts its response (`gpt/index.ts:1572`, `:1620`). Contract: the bridge -response carries a **per-attempt CSPRNG acknowledgement nonce**; the dynamic -renderer posts versioned `render_accepted` / `render_failed{reason}` messages -**to the kernel** (top window), carrying the nonce; the kernel validates -source ownership, nonce, token, `nav_gen`, and `refresh_gen` before any state -transition or callback. This protocol is pinned by tests for all three flows: -SSAT, client-Prebid, and nested SafeFrame. - -**G4c — Honest observation names.** The browser cannot see inside an opaque -APS frame, and the iframe's geometry is assigned by our own renderer — it -proves nothing about content. A nonempty `slotRenderEnded` proves GAM -delivered a creative container, not that the nested runner painted. The state -machine therefore records observations under accurate names — -`gam_nonempty`, `gam_empty`, `renderer_document_loaded`, `runner_loaded`, -`runner_failed` — and **APS attempts terminate at `render_accepted`** -(= authenticated `runner_loaded` ack) unless Amazon provides a real completion -acknowledgement. `render_confirmed` exists only for paths with same-origin -observable content (inline adm frames TS itself writes); it is never derived -from geometry or from PUC container delivery. Tests: accepted-but-blank, and -nonempty-`slotRenderEnded`-before-bridge-claim. - -**G4d — `nurl`/`burl` are separate business events.** OpenRTB 2.6 -distinguishes them: `nurl` is the win notice (implies neither delivery nor -billability); `burl` is the billable-event notice under exchange policy. -Today both fire together (`gpt/index.ts:459`). Contract — independent, -idempotent transitions: - -1. winner selection → fire `nurl`; -2. `render_accepted` (authenticated) → fire `burl` — this is the **declared - commercial policy** for APS given no paint acknowledgement exists, recorded - here explicitly rather than implied; -3. terminal failure after acceptance → no un-firing; the row is labeled - `billed_then_failed` so the policy's cost is measurable. - -**G4e — Fallback trigger.** GPT offers no cancellation (section 3.7), so a -timeout can race a late fill that arrives after a fallback has rendered and -billed. Contract: the opt-in direct fallback -(`[auction].client_render_fallback = "renderer"`) renders **only after an -explicit terminal empty event for the bound cycle** (`gam_empty` from G4a -attribution). A timeout emits diagnostics (`render_fail{bridge_claim_timeout}`) -and **never renders**. For publisher-owned (adopted) slots, fallback is -disabled entirely. TS-owned-slot timeout rendering is admitted only as a -possible future extension that must first destroy the slot to retire the -request, and is out of scope here. +IIFE-per-bundle with inlined imports (`build-all.mjs:46`, `bundle.rs:23`) +means module imports never share state across bundles (live proof: +`core/context.ts:11` vs `permutive/index.ts:102`). + +Contract — a versioned registration ABI on `window.tsjs._internal`: + +- Kernel ships only in `tsjs-core`, publishes + `tsjs._internal = { abi: 1, registry }` once (window sentinel). The kernel + constructs and registers core service instances during boot; integrations + register integration-scoped services during `install()`. +- **Version semantics (settling the mixed-version gap):** every service + registers with `(major, minor)`. `registry.get(name, {major, minMinor})` + succeeds iff an implementation with the same `major` and `minor ≥ minMinor` + is registered. The install manifest's plugin versions are **ranges with the + same semantics** (required major, minimum minor), not exact pins. + An incompatible service registration is **quarantined** — recorded, not + installed — and surfaced as `abi_mismatch`; an incompatible plugin as + `bundle_partial`. **First-wins applies only among compatible + registrations.** The old-deferred-bundle + new-core scenario therefore has a + deterministic verdict: the old plugin either satisfies the manifest range + and runs, or is quarantined loudly. +- Stateful services only via the registry at call time; stateless helpers may + be imported and inlined. Single-module-graph builds remain the successor + option behind the same surface. + +### G4 — Render lifecycle + +**G4a — Request-cycle protocol (no ordering assumptions).** GPT documents no +per-refresh identity and no completion-order guarantee, so the protocol +assumes neither: + +- Every observable request initiation is classified `ts | publisher`: TS's own + `display()`/`refresh()` calls open TS cycles; the wrapped publisher + entry-points and `slotRequested` events that match no TS cycle are recorded + as publisher-initiated. +- **TS serializes itself to at most one outstanding cycle per slot** — a new + TS refresh for a slot with a pending cycle waits or supersedes explicitly; + it is never concurrently pending. +- With ≤1 TS cycle outstanding, a `slotRenderEnded` is attributable iff no + untracked or publisher-initiated request overlaps it. **Any overlap marks + the slot `cycle_unattributable` and fails closed** (no fallback, no state + transition, console warning + disposition). +- `slotRenderEnded` is treated as "creative code injected", not "resources + loaded" — it can confirm delivery, never paint. +- The deterministic PUC/message harness exercises the protocol in CI, and a + **release-gating real-GAM overlap test** (publisher refresh racing a TS + cycle) validates the contract against actual GPT, since a FIFO-assuming + stub proves nothing. + +**G4b — Acknowledgement path.** Unchanged from revision 3 (review-accepted): +per-attempt CSPRNG nonce in the bridge response; the dynamic renderer posts +versioned accepted/failed messages to the kernel; the kernel validates source +ownership, nonce, token, `nav_gen`, `refresh_gen` before any transition or +callback; pinned for SSAT, client-Prebid, and nested SafeFrame flows. + +**G4c — Honest observations, `render_confirmed` removed.** The inline-adm +frames are sandboxed `srcdoc` documents with `allow-same-origin` deliberately +omitted (`gpt/index.ts:358`) — their origins are opaque, so revision 3's +"same-origin observable" premise was false. The event is removed entirely. +The taxonomy is now: `gam_nonempty`, `gam_empty`, `renderer_document_loaded`, +`runner_loaded`, `runner_failed`, `adm_document_loaded` (the iframe `load` +event for TS-written adm frames — document delivery, not paint). **Every +render path terminates at `render_accepted`** (authenticated per G4b where +the renderer protocol exists; `adm_document_loaded` for adm frames). No +observation claims paint. A future trusted completion acknowledgement (open +question 6) may reintroduce a confirmed state under a new name. + +**G4d — Win/billing notifications, scoped to paths that have them.** APS +**intentionally carries neither** `nurl` nor `burl` (`aps.rs:812` sets both +`None`; the minimized AAX envelope excludes notifications; the integration +guide documents that generic win/billing beacons are not fired for APS). APS +billing runs entirely inside the Amazon runner lifecycle, and this design +does not change the APS wire contract. + +For bid paths that do carry the URLs (PBS and other OpenRTB providers): + +- **Trigger semantics, published explicitly:** `nurl` fires when the render + attempt binds the bid to a cycle (Trusted Server's selection produced the + candidate GAM will render — the earliest point at which "win" is + meaningful for this pipeline); `burl` fires at the attempt's + `render_accepted`. Both are **attempt-scoped**, keyed by + `(trace_id, nav_gen, slot, refresh_gen, hb_adid)` as the idempotency key — + fired at most once per attempt, not page-wide. +- **Owner and mechanics:** the client render pipeline owns firing (as today, + `gpt/index.ts:459`), via `sendBeacon`/`no-cors fetch`, no retries (a beacon + either queues or is lost; retrying risks double-billing). +- Terminal failure after acceptance is labeled `billed_then_failed`; no + un-firing. + +**G4e — Fallback trigger.** The opt-in fallback +(`[auction].client_render_fallback = "renderer"`) renders only after a +**terminal `gam_empty` unambiguously attributed to a TS-initiated cycle** +(G4a). Timeouts are diagnostics-only and never render. Revision 3 disabled +fallback for adopted slots entirely, which — as the review noted — excludes +the common production path (pre-existing publisher slots are adopted and +refreshed, `gpt/index.ts:925`). Revised: **ownership does not gate the +fallback; attribution does.** An adopted slot whose attributed TS cycle ends +in `gam_empty` may fall back; any publisher-initiated or unattributable cycle +never triggers it. The success criteria and browser specs cover the adopted +case explicitly. ### G5 — Deployment contracts -- **Config rollback:** every new auction/config field is default-valued and - omitted from serialization at its default (`auction_config_types.rs:7` - denies unknown fields), so blobs written by a new binary remain readable by - the previous one unless an operator opts in. -- **Asset identity is path-based and retained.** A query hash the handler - ignores (`tsjs.rs:3`, `publisher.rs:294`) is not content addressing, and - redirecting an old hash to current bytes just executes new code under old - HTML, bootstrap flags, and ABI expectations. Contract: the content hash - moves into the **pathname** (`/static/tsjs//.js`); the server - serves through a hash→bytes manifest that **retains prior artifacts** beyond - the maximum HTML cache lifetime plus the deferred-load window (retention - floor: 7 days); `Cache-Control: immutable` only on exact hash matches; - unknown hashes answer `410 Gone` with `no-store` — never a redirect to - different bytes. Precomputed concatenations are keyed by the **ordered - module-ID vector** (order affects side effects), not the set. -- **Ingest routing:** the beacon route exists in all four adapters (Fastly, - Axum, Cloudflare, Spin) as an early, EC-free, filter-free route. Only Fastly - has a real sink today; the others accept-count-drop by explicit contract. -- **Storage:** a new dedicated datasource named `ts_client_events`; retention - **30 days**; production sampling default **10%** (operator-tunable); - schema versioned with the event enum. -- **Phase ordering** is section 8's; each phase ships behind a feature flag - with the named canary thresholds and rollback criteria in section 8. +- **Config rollback:** new config fields are default-valued and omitted from + serialization at defaults. **Rollback runbook rule:** after an operator has + opted into a new field, rolling the binary back requires restoring the + default and pushing the default-compatible blob first (this mirrors the + project's existing rollback guidance). +- **Asset identity and the artifact source (settling "not realizable"):** + hash in the pathname; and artifacts are **published to shared immutable + platform storage (KV/config store) as deploy stage 1, before any HTML + references them** — binaries serve the current vector from embedded bytes + (fast path) and everything else by hash lookup in shared storage. This + answers all four skew cases: new HTML hash `B` reaching an old instance + (lookup serves `B` from storage), a miss for retained `A` after only `B` is + embedded (lookup), already-issued legacy query-hash URLs (the legacy path + keeps serving current bytes with short-TTL, non-immutable caching through a + documented sunset), and renderer `/v2` reaching a `/v1`-era instance + (versioned renderer documents are published to the same storage in + stage 1). Two-stage deployment is the contract: **stage 1 publish + artifacts, stage 2 roll binaries/HTML.** Both rolling directions and the + legacy URL are tested. Retention: ≥ 7 days, which must exceed the HTML + cache lifetime — itself now bounded by contract (auction-bearing HTML is + `no-store` per G1; any cacheable non-auction HTML referencing tsjs sets + `max-age ≤ 300`). `Cache-Control: immutable` only on exact hash matches; + unknown hashes → `410 Gone`, `no-store`. Concatenations are keyed by the + **ordered module-ID vector**, precomputed in Phase 0 (which owns asset + identity). +- **Ingest routing:** the beacon route exists in all four adapters as an + early, EC-free, filter-free route; only Fastly has a real sink; others + accept-count-drop by explicit contract. +- **Storage:** datasource `ts_client_events`; retention 30 days; production + sampling 10%. These are **adopted defaults** (operator-tunable), no longer + open questions; the remaining open question is only whether non-Fastly + adapters get sinks (OQ5). +- **Phase gates are phase-specific** (section 8) — the render-fail canary + applies only from Phase 3 onward, because earlier phases don't create that + metric. --- ## 5. Workstream 1 — Observability -### 5.1 Event payload — minimized by design - -High-cardinality identifiers stay out of the beacon: no raw `hb_adid`, no raw -Prebid `adId`, no free-form slot strings. +### 5.1 Event payload — minimized, grouped per trace ``` -{ v: 1, events: [ - { trace_id, sampled, nav_gen, refresh_gen, seq, - t: "bid_received" | "targeting_set" | "bridge_request" | - "bridge_response_sent" | "render_attempt" | "render_accepted" | - "render_confirmed" | "render_fail", - slot, // configured slot id if in the injected slot set, else "s" - id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none" - matched, // bridge_request only: token/id equality result - source, // "renderer" | "adm" | "pbs-cache" | "gam" - reason } // render_fail only: closed enum below +{ v: 1, traces: [ + { trace_id, sampled, capability?, // capability: only in diagnostic mode + events: [ + { nav_gen, refresh_gen, seq, + t: "bid_received" | "targeting_set" | "bridge_request" | + "bridge_response_sent" | "render_attempt" | "render_accepted" | + "render_fail", + slot, // configured slot id if in the injected set, else "s" + id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none" + matched, // bridge_request only + source, // "renderer" | "adm" | "pbs-cache" | "gam" + reason, // render_fail only: closed enum below + width, height } // invalid_dimensions context only: bounded ints [0, 8192] + ] } ] } ``` -- Every stored string is either a member of a server-known allowlist (slot ids - from the injected config, enum members) or a bounded ordinal — nothing free - .form is persisted. -- Reason enum: `renderer_document_no_load`, `runner_no_load`, `runner_failed`, - `descriptor_invalid`, `bridge_id_mismatch`, `cycle_unattributable`, - `bridge_claim_timeout`, `gam_empty`, `no_render_source`, `slot_unresolved`, - `gpt_absent`, `pbjs_absent`, `bundle_partial`, `fallback_cancelled`, - `abi_mismatch`. (`renderer_no_ready` from revision 2 is split by the G4b/6.6 - protocol into document-load vs runner-load failures.) +- **Events are grouped per trace, and the diagnostic capability is a per-trace + field** — a navigation-spanning batch carries one group per trace, so one + batch-level capability can never be ambiguous, and an initial-navigation + capability never authorizes a client-minted SPA trace (that trace's + capability comes from the page-bids response, G1). +- Reason enum (closed; no interpolation — dimension context travels in the + bounded numeric fields): `renderer_document_no_load`, `runner_no_load`, + `runner_failed`, `descriptor_invalid`, `invalid_dimensions`, + `bridge_id_mismatch`, `cycle_unattributable`, `bridge_claim_timeout`, + `gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, + `pbjs_absent`, `bundle_partial`, `fallback_cancelled`, `abi_mismatch`. ### 5.2 Transport -`fetch(..., {keepalive: true, credentials: "omit"})` primary; `sendBeacon` as -the documented last-resort `pagehide` fallback (credentialed by platform -design, and its `true` means queued, not received — the handler ignores -credentials either way). Flush on `visibilitychange`/`pagehide` and every 5 s. - -### 5.3 Ingest wire contract (numeric, complete) - -- Route: `POST /_ts/client-events`, registered in all four adapters before - auth/EC/filters. Content type: `application/json` only (no - `Content-Encoding`; compressed bodies rejected). Responds - `204 Cache-Control: no-store`. -- Limits enforced **before parse or log**: body ≤ **16 KiB**; ≤ **64** events - per batch; any string field ≤ **64** chars; `trace_id` must match - `^[0-9a-f]{32}$`; `nav_gen`/`refresh_gen`/`seq` are integers in - `[0, 2³¹)`. Violation → `204` (accepted-and-dropped) + abuse counter; the - endpoint never echoes input. -- Same-origin enforcement: `Sec-Fetch-Site: same-origin` when present; - otherwise `Origin` must match the serving host; **absent both → reject** - (drop-and-count). All strings are structurally serialized (never - interpolated into log lines). -- Client IP for rate limiting is derived per adapter from its documented - trusted source (Fastly: the platform client IP; Axum: configured trusted - proxy header; Cloudflare/Spin: platform equivalents). Rate limiting uses the - platform limiter where one exists (Fastly); portable adapters ship a - best-effort in-memory limiter and the policy is **fail-open with an abuse - counter** (dropping telemetry must never block ad delivery). -- **Diagnostic mode is a server-injected capability, not a query flag.** The - tester gate (cookie) is evaluated server-side at HTML render; the page - receives a short-lived signed capability token (HMAC over - `trace_id + expiry`, ≤ 15 minutes) which the client echoes in the batch. - The ingest handler verifies the signature — this works with - `credentials: "omit"` because the capability travels in the payload, and a - public query flag alone can never switch a session to unsampled. +`fetch(..., {keepalive: true, credentials: "omit"})` primary. The `pagehide` +fallback is `navigator.sendBeacon(url, new Blob([json], {type: +"application/json"}))` — the Blob type satisfies the ingest media-type +contract (a bare string would arrive as text); it is credentialed by platform +design and its `true` means queued, not received; the handler ignores +credentials either way. Flush on `visibilitychange`/`pagehide` and every 5 s. + +### 5.3 Ingest wire contract + +- `POST /_ts/client-events` in all four adapters, before auth/EC/filters. + `Content-Type: application/json` only; no `Content-Encoding`. Responds + `204 Cache-Control: no-store`; never echoes input. +- Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars; + `trace_id ^[0-9a-f]{32}$`; integers in `[0, 2³¹)`; width/height in + `[0, 8192]`. Violation → drop-and-count with `204`. +- Same-origin: `Sec-Fetch-Site: same-origin` when present, else `Origin` + matching the serving host; **absent both → drop-and-count**. +- **Rate limiting fails closed for telemetry:** when the limiter denies, or + when a portable adapter's best-effort limiter is unavailable or errors, the + request is dropped early with `204` (count only, no parse, no sink). Ad + delivery is unaffected by construction because this route serves nothing. +- **Trusted client address, per adapter, concretely:** Fastly — the + platform's client IP API; Axum — the rightmost `X-Forwarded-For` entry + beyond `trusted_proxy_hops` (a required config value when the beacon is + enabled; without it, the socket peer address is used and forwarded headers + are ignored); Cloudflare — `CF-Connecting-IP`; Spin — the platform client + address. Spoofable headers are never trusted beyond the configured hop + count. +- **Diagnostic capability:** server-issued, HMAC over + `trace_id + expiry` (≤ 15 min), delivered via the G1 boot field (initial + trace) or the page-bids response (SPA traces), echoed per trace group. + Signature verification is what switches a trace to unsampled — a public + query flag alone never does. ### 5.4 Two modes, honestly separated -- **Production telemetry:** sticky-sampled (default 10%), SLO: **a delivery - failure mode affecting ≥ 1% of impressions is visible in `ts_client_events` - within one hour**. -- **Diagnostic mode:** capability-gated, unsampled, full event stream plus - console mirroring — the "one page load names the failing reason" tool. +- **Production telemetry** (sink-backed deployments only — today Fastly): + sticky-sampled (10%). SLO, fully parameterized: a failure mode affecting + ≥ 1% of **sampled render attempts** is visible in `ts_client_events` within + one hour, evaluated only when the deployment produced ≥ 10,000 sampled + render attempts in that hour, with sink ingestion freshness ≤ 5 minutes; + sink outages pause the SLO clock and are alarmed separately. Non-sink + adapters are explicitly out of SLO scope, and no global gate depends on + their beacon data. +- **Diagnostic mode:** capability-gated, unsampled, full stream + console + mirroring — the "one page load names the failing reason" tool. ### 5.5 Server-side drop-reason surfacing -- Emit a bounded structured summary **whenever any bid is dropped** (per-slot - reason counts, capped), not only when zero survive. -- Add `drop_reasons` to auction telemetry rows; add the drop summary to the - initial-HTML `ts-debug` comment; `/_ts/page-bids` (JSON) gains a gated - structured `debug` field under the same tester gate. -- Startup validation warnings: APS enabled while - `allow_script_creatives = false`; any direct provider configured alongside a - mediator without the 6.1 merge strategy. +- Bounded structured summary whenever **any** bid is dropped (per-slot reason + counts, capped); `drop_reasons` added to auction telemetry rows; drop + summary in the initial-HTML `ts-debug` comment; `/_ts/page-bids` gains a + tester-gated structured `debug` field. +- Startup warnings: APS enabled with `allow_script_creatives = false`; direct + provider configured alongside a mediator without 6.1's merge strategy. --- @@ -396,128 +407,77 @@ credentials either way). Flush on `visibilitychange`/`pagehide` and every 5 s. ### 6.1 Mediation: opt-in merge, `mediator_only` stays the default -- `[auction].winner_selection = "mediator_only"` (default, today's behavior, - now explicit) or `"merge_highest_cpm"` (opt-in); omitted from serialized - blobs at the default (G5). -- `merge_highest_cpm` semantics: comparison in decoded CPM; **currency - mismatch is a rejection** (the mismatched bid is dropped with a selection - reason; no conversion in v1); slot floors apply to both populations; ties - break to the mediator; a mediator timeout degrades to direct-provider - selection and is reported. -- **Deduplication key:** the server constructs the mediator's input, so it - records provenance at forwarding time — `(provider_name, upstream_bid_id)` - per candidate — and carries a provenance map keyed by the id it sent. - A mediator bid whose id maps back to a forwarded candidate counts once, as - provenance `mediator`. A mediator bid whose id was **transformed beyond the - map** is treated as distinct mediator demand (documented limitation). -- **Deal priority is out of scope for v1.** The internal `Bid` - (`types.rs:231`) carries no deal identity; inventing a priority rule the - model cannot express would be fiction. Extending the bid model with - `deal_id`/deal type and a deal-first rule is recorded as follow-up work; - until then deals compete by CPM like everything else and the limitation is - documented in the config reference. -- One candidate-selection helper serves **both** mediation lifecycles - (ordinary/page-bids, `orchestrator.rs:412-431`; initial-SSAT split - dispatch/collect, `orchestrator.rs:1320`), with tests for each. -- A **selection report** (`winner_source`, `mediator_superseded`, - `currency_rejected`, dedup hits) is emitted separately from - delivery-conversion drop reasons. +As revision 3 (review-accepted), with one tightening: a mediator bid whose id +the provenance map cannot resolve, **for a slot where the same provider had +forwarded candidates**, is counted under a dedicated +`mediator_provenance_unresolved` metric and logged at `warn` — surfacing +possible self-competition instead of silently treating it as distinct demand. +Deal priority remains out of scope (the `Bid` model carries no deal identity; +recorded as follow-up). Currency mismatch remains rejection. One selection +helper serves both mediation lifecycles, with tests for each. ### 6.2 Dimensions: the contract is "request what you accept" -Revision 2's operator `accept_sizes` allow-list is withdrawn on the review's -sharper observation: if an alternate size is acceptable, it belongs in the -slot's **requested formats** — APS should be asked for it. Accepting an -unrequested response size would conceal an upstream protocol violation. - -- Exact size membership (`aps.rs:657-668`) remains the admission rule, - unchanged. -- The fix is configuration plus visibility: the drop summary (5.5) names the - rejected size per slot (`invalid_dimensions{300x600}`), so an operator sees - exactly which format to add to the slot's `formats` if they want that - demand. Documentation gains a "sizing your slots for APS" section. -- No `hb_size` key, no admission relaxation, no new config. +Exact size membership stays. The fix is visibility plus configuration: the +drop summary names the rejected size per slot via +`reason: invalid_dimensions` with bounded numeric `width`/`height` fields +(closed enum preserved), and documentation gains "sizing your slots for APS." ### 6.3 Script creatives -Keep the secure default (`allow_script_creatives = false`) but make the -consequence loud (5.5) and document the enablement path for TAM-heavy -publishers. +Secure default kept; consequence made loud (5.5); enablement path documented. ### 6.4 Render identity -Implemented exactly as G2 (token scope, format, TTL, per-navigation registry -keying, one-time consumption, cache-path regression tests). +As G2. ### 6.5 Fallback rendering -Implemented exactly as G4e: renders only on an attributed terminal -`gam_empty`; timeouts are diagnostics-only; disabled for adopted slots. The -direct renderer is converted to an awaitable API with cancellation and a -terminal reason first; the fallback lands only after that conversion. +As G4e — attribution-gated, not ownership-gated; timeouts never render; the +renderer is converted to an awaitable API with cancellation and terminal +reasons before the fallback lands. ### 6.6 Renderer endpoint — unconditional, versioned, observable -Topology is resolved now rather than left conditional: - -- **The static renderer document route registers unconditionally in every - adapter.** It contains no configuration, no secrets, and validates its input - client-side; serving it cannot leak anything, and conditional registration - is exactly what created the silent cross-deployment failure class. (The APS - _provider_ stays config-gated; only the static document is unconditional.) -- The document is **versioned in its path** - (`/integrations/aps/renderer/v1`) and served `Cache-Control: no-store`; - descriptor compatibility across N/N−1 is guaranteed by the outer-tolerant - validation of 6.7. The client pins the version it targets. -- Startup validation fails loudly if any configured auth handler pattern - covers `/integrations/aps/renderer`. -- **Two-stage acknowledgement (with G4b):** the document first posts an - authenticated `document_loaded` (proving route + auth + CSP allowed the - document itself), then the separate runner-load result. This splits the old - blind timeout into `renderer_document_no_load` (route/auth/stale-CDN/network) - vs `runner_no_load` / `runner_failed` (Amazon script or CSP) — distinct - signals, as the success criteria require. -- Server-side: route status/version counters (requests, unknown-version, - auth-blocked) join the telemetry rows. -- **CSP changes ship report-only first** (`Content-Security-Policy-Report-Only` - canary with a bounded report endpoint), then enforce; each added source is - justified in a comment and covered by the browser spec. Tests cover broad - auth patterns, stale versions, and CSP failures on all adapters. - -### 6.7 One descriptor schema — structural generation, semantic validators kept - -- The wire truth is the tagged enum `BidRenderer` (discriminator lives there, - not on `ApsRendererV1` — `types.rs:188-211`); the generated schema is the - full tagged envelope. -- Generation lives in a **separate wire-schema crate** (or host-side xtask) — - core already depends on `trusted-server-js` (`Cargo.toml:45`), so the - reverse edge would be a cycle. Generated TS artifacts are checked in; CI - fails on staleness. -- Generation covers structure only; the semantic security checks stay - hand-written on both sides (URL/origin policy, canonical base64, length - bounds, the exact one-bid envelope projection, cross-field equality). - **Unknown-field tolerance applies only to the outer versioned descriptor; - the decoded AAX envelope remains an exact projection.** -- A shared positive + adversarial corpus runs through the Rust validator, the - TS validator, and the inline renderer document in CI. +- The static renderer document route registers unconditionally in every + adapter (the APS provider stays config-gated). Startup validation fails + loudly if an auth handler pattern covers it. +- **Caching matches immutability:** `/integrations/aps/renderer/v1` is an + immutable artifact — its bytes change only by shipping `/v2` — so it is + served with `Cache-Control: immutable` (long max-age), published to shared + artifact storage in deploy stage 1 like every versioned asset (G5), which + also answers version-skew (`/v2` requests reaching older instances are + served from storage). Revision 3's `no-store` contradicted the versioning + and is corrected. +- Two-stage acknowledgement (G4b): authenticated `document_loaded`, then the + runner-load result — splitting `renderer_document_no_load` from + `runner_no_load`/`runner_failed`. +- **Server route counters are aggregate** (requests, unknown-version, + auth-blocked): the document request carries no trace (the nonce travels in + the URL fragment, which never reaches the server), so no row-level join is + claimed. +- **CSP report-only canary, fully specified:** reports go to a dedicated + `POST /_ts/csp-reports` route (same pre-parse caps and same-origin rules as + 5.3; credentials ignored; rate-limited fail-closed); stored as **aggregate + counters only** (directive, blocked-origin **host only** — full URLs + redacted) on sink-backed adapters, count-and-drop elsewhere; enforcement + follows only after a clean canary window. + +### 6.7 One descriptor schema + +As revision 3 (review-accepted): tagged-envelope schema generated from a +separate wire-schema crate/xtask; semantic validators hand-written on both +sides; outer-tolerance only, exact AAX projection; shared +positive + adversarial corpus across Rust, TS, and the inline document; +staleness CI. ### 6.8 Bridge hardening -- **Ownership proof stays source-first**, and the SafeFrame extension is - bounded: the kernel maintains a map of known slot-root `WindowProxy` objects - (the iframes GPT created under each slot element); on a message, it walks - the **sender's own parent chain** (`event.source.parent`, …) up to depth - **5**, looking for a known root — it never enumerates or recursively scans - an attacker-controllable frame tree. Unresolvable source → refuse with - `bridge_id_mismatch`. -- Adversarial tests: wrong-slot tokens, nested foreign frames, replayed and - duplicated messages, stolen tokens, previous-navigation tokens, plus the - positive SafeFrame case. -- Blanket top-of-listener hygiene: parse and ownership-check before branch - logic. -- Delete the dead duplicate renderer branch (C5); renderer branches emit the - same trace records as adm/cache branches under G4's taxonomy and the G4d - `nurl`/`burl` split (C7). +As revision 3 (review-accepted): source-first ownership with the bounded +parent-chain SafeFrame walk (depth 5, known slot-root `WindowProxy` map, no +tree scans); adversarial test set; top-of-listener hygiene; dead branch +deleted; renderer branches emit trace records under G4's taxonomy, with G4d +notifications only where the bid path carries them (never APS). --- @@ -525,283 +485,211 @@ Topology is resolved now rather than left conditional: ### 7.1 Layering -``` -kernel/ boot, config, queue, event bus, log, beacon, sessions -adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access -services/ slots (registry+handoff), auction client, render engine, consent -integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services) -``` - -Boundary lint in CI (`import/no-restricted-paths`): `kernel` imports nothing -above it; `adapters` import kernel only; `services` import kernel + adapters; -`integrations` import kernel + services, never each other. Stateful services -via the G3 ABI only. +Kernel / adapters / services / integrations exactly as revision 3, with the +boundary lint in CI. Stateful services via the G3 ABI only. -### 7.2 Adapters: explicit absence, without giving up on late loaders +### 7.2 Adapters -`present | pending | timed_out` per external global; `timed_out` is -non-terminal (late GPT/pbjs/CMP arrival transitions to `present` and drains -what is still valid); individual queued operations carry their own timeouts -and expire with a disposition reason. +`present | pending | timed_out` with non-terminal `timed_out` (late loaders +transition to `present`); per-operation timeouts with disposition reasons. ### 7.3 Slot registry service -One registry owns slot knowledge (publisher- vs TS-defined, adoption, handoff -claims, responsive resolution, pending request cycles per G4a, targeting-key -history), keyed by `WeakMap` plus a div-id index, -kernel-owned via the ABI. Expandos on live GPT objects are eliminated. +Kernel-owned registry (`WeakMap` + div-id index) +holding ownership, adoption, handoff claims, responsive resolution, pending +request cycles (G4a), and targeting-key history. No expandos on GPT objects. ### 7.4 Global namespace policy — with a compatibility window -- One owned global, `window.tsjs`; public API versioned; coordination state - under `tsjs._internal` (G3). **The public queue keeps its existing name: - `window.tsjs.que`** (`types.ts:259`, drained at `core/index.ts:25`) — - revision 2's `cmd` was an error; renaming a public surface silently would - violate this very section. -- Inventory first: every current global classified public - (`globalThis.tscreative`, `tsCreativeConfig`, `tsjs.que`) or private - (`__tsjs_*` flags, expandos, sentinels). -- **Public globals: dual-read/write for a bounded window — two release - cycles, minimum 60 days — ending only after an adoption gate: beacon-observed - old-name usage below 0.1% of traces for 14 consecutive days.** Pre-init - compatibility tests pin that config set before the bundle loads keeps - working. -- Private globals migrate immediately: dead expando writes deleted now; slot - state into `SlotRecord`; function sentinels into a kernel `WeakSet`; boot - flags into `tsjs` boot fields. -- `requestAds` keeps its void signature; failure surfacing arrives as a **new - versioned async API** (`tsjs.requestAdsAsync(...): Promise`) - rather than changing the existing contract. +As revision 3 (review-accepted): `window.tsjs` + `tsjs._internal`; the public +queue keeps its real name **`tsjs.que`**; public globals +(`tscreative`, `tsCreativeConfig`, `tsjs.que`) get dual-read/write for two +release cycles / ≥ 60 days, closing only on the adoption gate (old-name usage +< 0.1% of traces for 14 days, measured on sink-backed deployments); +`requestAds` keeps its void signature; `requestAdsAsync` is the new versioned +API; private globals migrate immediately. ### 7.5 Messaging module -All `postMessage` traffic through one module: versioned envelopes, message -name constants, the G4b acknowledgement nonces, source validation per 6.8, one -audit point. +All `postMessage` through one module: versioned envelopes, name constants, +G4b nonces, 6.8 source validation. A **minimal** messaging module (envelope + +constants + validation helpers used by the bridge) lands early (Phase 1) so +Phase 3 does not depend on Phase-4 structure; the full migration of every +legacy call site completes in Phase 4. ### 7.6 Plugin lifecycle and session model -- **Activation:** Rust owns integration selection today and continues to — the - server injects a **versioned install manifest** (enabled plugin ids + - expected versions, in injection order) into the pre-core `tsjs.que`. The - kernel executes the manifest on boot; nobody else calls install in - production (the API remains callable for tests). -- `tsjs.definePlugin(id, version, install, dispose)`: synchronous `install` - by default; a plugin may return a promise, but anything `adInit` depends on - (gpt, prebid shim registration) must complete synchronously and is listed as - such in the manifest. Late registration after the manifest requested the id - installs on arrival (pending-install queue) bounded by a missing-module - timeout emitting `bundle_partial`. A stale async completion (arriving after - its `RuntimeSession` was disposed) is discarded. Duplicate `(id, version)` - is a no-op; a different version for a registered id: first-wins + loud - telemetry. Disposal runs in reverse install order. -- **Session model, split as the review required:** - - `RuntimeSession` (page lifetime): bridge listener, history hook, pbjs - subscriptions, adapters, beacon queue. - - `NavigationSession` (per SPA navigation): `trace_id`, render attempts, - slot aliases, targeting history, navigation-scoped timers/observers. - - `RenderAttempt` (per G4a cycle): state machine instance, ack nonce. - Each owns an **enumerable** disposal inventory; navigation disposes - `NavigationSession` children only. -- Per-plugin exception isolation: a plugin that throws during install is - quarantined and reported; it cannot halt the bundle. -- Error policy: no empty `catch`; every catch handles, logs with context, or - emits a disposition reason. The auction fetch gets timeout + - `AbortController`. -- **Console logging is retained, not replaced.** Every issue-surfacing - condition keeps (or gains) a `log.warn` debuggable from DevTools; existing - warnings survive verbatim or strengthened; `debug`-level failure paths that - indicate delivery or security-relevant failures are promoted to `warn`; - every `render_fail`/dependency-timeout disposition emits a paired `warn` - with the same reason code. +As revision 3 (review-accepted), with G3's sharpened version semantics: +manifest versions are ranges (major + minMinor); quarantine on +incompatibility; first-wins only among compatible. Sessions: +`RuntimeSession` / `NavigationSession` / `RenderAttempt` with enumerable +disposal inventories. Error policy: no empty `catch`; auction fetch gets +timeout + `AbortController`. **Console logging retained, not replaced** +(paired `warn` with the beacon's reason code; `debug`-level +delivery/security failures promoted to `warn`). ### 7.7 The bootstrap problem -Target: shrink the inline `gpt_bootstrap.js` to a queue-and-flags stub with -the bundle replaying recorded calls on install. This is **not a pure move** — -replay changes observable ordering — so it ships behind its own flag with -browser specs extended to cover replay timing, and the no-bundle fallback -("ads still render if the bundle fails", pinned by `gpt.rs:1174-1179`) is -**generated from the same TypeScript source** at build time. +As revision 3: queue-and-flags stub + bundle replay behind its own flag with +replay-timing specs; the no-bundle fallback generated from the TypeScript +source. ### 7.8 GPT correctness fixes carried with the restructure -- Restore the #922 orphan-slot recovery and `updateRender` enrichment (verify - against open PR #997; land whichever is canonical). -- `changeCorrelator: false` on TS-initiated refreshes; correlator behavior - becomes a documented, configurable decision. +- **Unconditional early GPT event subscription:** the `slotRenderEnded` + (and `slotRequested`) listeners register on the command queue at install, + no longer gated behind `!ts.servicesEnabled` (`gpt/index.ts:1017`) — G4a + cannot work on bootstrap-first pages otherwise. Recording is idempotent so + double-registration cannot double-count. +- Restore #922/#997 attribution and orphan recovery. +- `changeCorrelator: false` on TS-initiated refreshes (configurable). - `enableSingleRequest()` only when GPT services are not already enabled. -- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` - alongside its console warning. +- Ambiguous responsive resolution emits `render_fail{slot_unresolved}`. ### 7.9 Decomposition targets -| Today | Target | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | -| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (moves onto slot registry), eids, diagnostics | -| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | -| `core/trace.ts` (record model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | -| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split into public API vs internal coordination state | +As revision 3 (gpt/prebid splits, script-guard consolidation, trace model/UI +split, `global.d.ts` fix). ### 7.10 Performance -Client-side speedups with enforced budgets: smaller synchronous bundle -(script-guard consolidation, single APS module via the ABI, dead-code -deletion, trace-overlay extraction); slot resolution once per navigation; -bounded telemetered waits. Budgets, concretely: per-bundle raw/gzip/Brotli -bytes for the exact ordered module vector of the reference config, compared -to a checked-in baseline with **+5% tolerance**; the -bids-script-to-first-`display()` browser assertion runs **20 iterations** and -gates on **p90**. Server-side: precompute bundle bytes + hash per ordered -module vector (deploy/config-time), benchmark server CPU/heap before/after. +Budgets tightened per review: per-bundle raw/gzip/Brotli for the exact +ordered module vector vs a checked-in baseline, **+5% byte tolerance**; +browser timing assertion (bids-script-to-first-`display()`) on a **pinned CI +runner class and pinned browser version**, **5 warm-up runs discarded, 50 +measured samples, gate on p90 with +10% latency tolerance**; server bench +(precomputed concatenation) gates CPU and heap at **±10% vs baseline** with +pinned tool versions. Precompute lands in Phase 0 with asset identity (G5). ### 7.11 Toolchain and dependency currency -- **TypeScript:** the manifest floor is `^5.5.4` but the lockfile already - resolves **5.9.3** (`package-lock.json`), so the code compiles on 5.9 today. - Action: raise the manifest floor to the resolved 5.9 line (making the floor - honest), then evaluate the next major as its own gated PR; adopt - `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, - `verbatimModuleSyntax`. -- **Dev toolchain to latest stable** (eslint + plugins, prettier, jsdom, - `@playwright/test`, `@types/node` aligned to the pinned Node), each bump its - own mechanical CI-gated PR with changelog review — this library monkeypatches - `fetch`, `sendBeacon`, and DOM prototypes, so jsdom/Playwright behavior - changes are real risks. -- **`prebid.js` is excluded from casual bumps** (runtime Prebid is the - manifest-locked external R2 bundle); the npm pin and deployed bundle version - stay documented together. -- **Standing policy:** monthly dependency review; no migration phase starts - more than one minor behind latest stable. +As revision 3 (review-accepted): raise the TypeScript floor to the resolved +5.9 line, then evaluate the next major separately; strictness flags on; dev +toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from casual +bumps; monthly review policy. --- ## 8. Migration plan -Ordered so every phase's prerequisites precede it (the review's required -order). Every phase ships behind a feature flag; shared canary criteria: no -increase in `render_fail` rate beyond **+0.5% absolute** on canary traffic -over 24 h, no new console errors in the browser-spec run, rollback = flag off -(phases 0–2 are additive; later phases keep dual paths until their gate). - -- **Phase 0 — Asset identity, contracts, toolchain.** Path-based immutable - asset identity + manifest retention + rolling-deploy tests (G5) — this lands - **before** anything makes ABI compatibility load-bearing. Toolchain floors - (7.11). Contracts G1–G5 recorded as code-adjacent docs. Delete the dead - expando writes. Server drop-reason surfacing (5.5) — server-only, no client - dependency. -- **Phase 1 — Kernel ABI and sessions.** Minimal kernel in `tsjs-core` - publishing the versioned registry (G3); `RuntimeSession` / - `NavigationSession` scopes (7.6); context-provider fix as the ABI proof; - install manifest injection. -- **Phase 2 — Trace and beacon.** Server correlation nonce for `nav_gen 0` + - page-bids trace echo (G1); beacon service on the Phase-1 kernel; ingest - route in all four adapters (5.3); diagnostic capability; `ts_client_events` - datasource. -- **Phase 3 — APS delivery.** Wire-schema crate + corpus (6.7); mediation - helper + opt-in merge (6.1); render token (6.4); unconditional versioned - renderer route + two-stage ack (6.6, G4b); bridge hardening (6.8); GPT - request-cycle protocol + render state machine + awaitable renderer + - `nurl`/`burl` split (G4a–G4d); the opt-in fallback (G4e); restore #922/#997 - attribution; correlator and SRA fixes (7.8). APS renders after this phase - wherever GAM line items and configuration permit. -- **Phase 4 — Structure.** Full layering + boundary lint, plugin lifecycle - completion, adapters, full slot registry, messaging module, namespace - migration window (7.4), server bundle precompute (7.10). -- **Phase 5 — Decomposition.** File splits (7.9), script-guard consolidation, - bootstrap shrink behind its own flag with replay-timing specs (7.7), end of - the public-global compatibility window (gated per 7.4). +Each phase ships behind a feature flag with **phase-specific gates** (the +render-fail canary cannot evaluate phases that predate the metric): + +- **Phase 0 — Asset identity, contracts, toolchain.** Two-stage artifact + publishing (shared immutable storage) + path-based identity + ordered-vector + precompute + rolling-deploy tests in both directions + legacy-URL test; + toolchain floors; contracts G1–G5 as code-adjacent docs; delete dead expando + writes; server drop-reason surfacing. + _Gate:_ zero unexpected `410`s and zero legacy-URL breakage on canary; asset + hit/miss counters nominal. +- **Phase 1 — Kernel ABI, sessions, minimal messaging, minimal cycle + registry.** Versioned registry with `(major, minor)` semantics; + `RuntimeSession`/`NavigationSession`; install manifest; the minimal + messaging module (7.5) and the cycle-aware slot-record core (G4a's queue) + land here so Phase 3 has its dependencies; unconditional early GPT + subscriptions (7.8). + _Gate:_ ABI install-success counters clean; zero `abi_mismatch` on canary; + no listener regression in browser specs. +- **Phase 2 — Trace and beacon.** Server-minted initial trace + page-bids + `X-TSJS-Trace-Id` echo + capability issuance (G1, 5.3); beacon service; + four-adapter ingest; `ts_client_events`. + _Gate:_ ingest acceptance/drop/abuse counters nominal; trace join rate on + sink-backed canary ≥ 95% of sampled traces. +- **Phase 3 — APS delivery.** Wire-schema crate + corpus; mediation helper + + opt-in merge; render token; unconditional versioned renderer route + + two-stage ack; bridge hardening; request-cycle protocol + render state + machine + awaitable renderer + scoped notifications (G4a–G4d); the opt-in + fallback (G4e); restore #922/#997; correlator and SRA fixes. + _Gate:_ `render_fail` rate within +0.5% absolute of pre-flag baseline over + 24 h on canary; fill/latency/billing volume deltas within agreed bounds + (billing measured against GAM/server-side reporting, not the beacon); + release-gating real-GAM overlap test green. +- **Phase 4 — Structure.** Full layering + boundary lint; plugin lifecycle + completion; adapters; full slot registry; full messaging migration; + namespace window (7.4). + _Gate:_ boundary lint zero exceptions; disposal-inventory leak tests green. +- **Phase 5 — Decomposition.** File splits; script-guard consolidation; + bootstrap shrink (own flag, replay-timing specs); compatibility-window + close (adoption-gated). + _Gate:_ bundle budgets and timing assertions hold; adoption gate met before + any removal. --- ## 9. Test acceptance matrix -Blocking CI is hermetic (the deterministic PUC/message harness); a separate -staged smoke suite covers real GAM line items and is release-gating, not -PR-gating. - -| Area | Must cover | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Mediation | both lifecycles; ties, floors, currency rejection; duplicate demand via provenance map; transformed mediator ids; `mediator_only` default preserved; rollback blob round-trip (omitted defaults) | -| Cache identity | non-APS cache-backed bids byte-identical (`hb_adid` + coordinates); PUC `?uuid=` fetch path | -| Render token | `^[a-z0-9]{12}$`; CSPRNG source; in-auction collision retry; 15-min TTL; one-time consumption; per-`(trace, nav_gen)` registry scoping | -| Request cycles | pending-cycle attribution; overlapping refreshes; late `slotRenderEnded`; nonempty-before-bridge-claim; `cycle_unattributable` fail-closed | -| Ack protocol | nonce validation (source, token, nav_gen, refresh_gen); SSAT + client-Prebid + nested SafeFrame flows; stale/replayed acks | -| Render semantics | no `burl` before authenticated `render_accepted`; `nurl` at selection independent of delivery; `billed_then_failed` labeling; accepted-but-blank; no `render_confirmed` from geometry or PUC container | -| Fallback | renders only on attributed `gam_empty`; timeout is diagnostics-only; adopted-slot fallback disabled; SPA cancellation; slot destruction; exactly-once terminal transition | -| Bridge security | wrong-slot tokens; nested foreign frames; replay + duplicates; stolen tokens; previous-navigation tokens; bounded parent-chain walk (depth 5); SafeFrame positive case | -| Beacon | initial-nav server nonce join; page-bids trace echo; per-event envelope across navigation-spanning batches; `(trace_id, nav_gen, seq)` dedup; ingest abuse (oversize, malformed, cross-origin, absent Origin); capability sig | -| Schema | generated-artifact staleness; adversarial corpus through Rust + TS + inline validator; outer-tolerance vs exact AAX projection | -| Runtime ABI | one kernel under concatenation; deferred registration after manifest request; per-plugin failure isolation; **mixed-version delivery (old deferred bundle + new core)**; abi-mismatch refusal | -| Lifecycle | late-loaded GPT/pbjs (`timed_out → present`); RuntimeSession vs NavigationSession disposal inventories; stale async install completion; pre-init `tsCreativeConfig` and `tsjs.que` compatibility; dual-name global window | -| Delivery | rolling-deploy simulation (old HTML + retained old assets); unknown hash → 410 `no-store`; immutable only on exact match; ordered-module-vector keying; deterministic raw/gzip/Brotli budgets vs baseline | -| Renderer endpoint | route present in all four adapters; auth-pattern startup failure; stale version behavior; CSP report-only canary; `document_loaded` vs runner-result split | -| Adapter parity | ingest + renderer routes + drop-reason surfacing behave equivalently on Fastly/Viceroy vs Axum vs Cloudflare vs Spin | -| Policy | script-creative startup warning; `invalid_dimensions{WxH}` drop naming; page-bids `debug` field gating; diagnostic-mode unsampled completeness | +Blocking CI is hermetic; the staged smoke suite (real GAM line items, +including the G4a overlap test) is release-gating. + +| Area | Must cover | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Mediation | both lifecycles; ties, floors, currency rejection; provenance dedup; transformed ids → `mediator_provenance_unresolved`; `mediator_only` default; rollback blob round-trip | +| Cache identity | non-APS cache-backed bids byte-identical; PUC `?uuid=` path | +| Render token | format/CSPRNG/in-auction retry/TTL/one-time/per-`(trace, nav_gen)` scoping | +| Request cycles | ts-vs-publisher classification; one-outstanding-TS-cycle serialization; publisher refresh overlapping TS cycle → `cycle_unattributable` fail-closed; late events; **real-GAM overlap (release gate)** | +| Ack protocol | nonce validation (source, token, nav_gen, refresh_gen); SSAT + client-Prebid + nested SafeFrame; stale/replayed acks | +| Render semantics | notifications only on carrying paths (never APS); `nurl` at bind, `burl` at accepted, attempt-scoped idempotency; `billed_then_failed`; no paint claims (`adm_document_loaded` labeling); accepted-but-blank | +| Fallback | renders only on attributed `gam_empty` (adopted **and** TS-owned); publisher-initiated cycles never trigger; timeout diagnostics-only; SPA cancellation; destruction; exactly-once terminal | +| Bridge security | wrong-slot/stolen/replayed/prior-navigation tokens; nested foreign frames; bounded parent-chain walk; SafeFrame positive | +| Beacon | initial-trace join; page-bids header echo + response trace/capability; per-trace grouping across navigation-spanning batches; `seq`-gap partial traces; ingest abuse incl. absent Origin; capability signature; sendBeacon Blob | +| Schema | staleness; adversarial corpus ×3 validators; outer-tolerance vs exact AAX projection | +| Runtime ABI | one kernel under concatenation; deferred late registration; failure isolation; **mixed-version verdicts (compatible-range install vs quarantine)**; first-wins among compatible only | +| Lifecycle | `timed_out → present`; session disposal inventories; stale async install; pre-init `tsCreativeConfig` + `tsjs.que`; dual-name window | +| Delivery | two-stage deploy simulation **both rolling directions**; new-HTML-hash on old instance (storage lookup); retained-hash miss; legacy query-hash sunset path; unknown hash 410 `no-store`; immutable exact-match only; ordered vector | +| Renderer endpoint | route in all adapters; auth-pattern startup failure; `/v1` immutable caching; version-skew via storage; `document_loaded` vs runner split; CSP report route caps/redaction | +| Adapter parity | ingest, CSP-report, renderer routes and drop-reason surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | +| Policy | script-creative warning; `invalid_dimensions` + bounded width/height; page-bids `debug` gating; diagnostic unsampled completeness; trace-bearing HTML `private, no-store` invariant | --- ## 10. Alternatives considered -1. **Keep patching APS point-failures without telemetry.** Rejected: three - consecutive correct fixes have not produced ads. -2. **Direct-render APS always (skip GAM/PUC).** Changes GAM reporting/pacing - semantics unilaterally; kept as the opt-in, `gam_empty`-gated fallback. -3. **Single module graph / shared chunks instead of the registration ABI.** - Cleaner long-term; changes the delivery pipeline now; successor option - behind the same ABI surface. -4. **Full library rewrite in one branch.** Rejected: thin browser-spec safety - net in exactly the changing areas. -5. **Drop the ES5 bootstrap entirely.** Loses the pinned "ads render if the - bundle fails" guarantee; generated fallback keeps it. -6. **Timeout-triggered fallback rendering.** Rejected (G4e): GPT requests - cannot be cancelled, so timeout rendering races late fills; only an - attributed terminal empty event may trigger rendering. +Unchanged from revision 3 (patching without telemetry; always-direct-render; +single module graph; big-bang rewrite; dropping the bootstrap; +timeout-triggered fallback — all rejected for the recorded reasons). ## 11. Risks -- **Merge strategy misconfiguration:** mitigated by `mediator_only` default, - the selection report, omitted-default serialization. -- **Beacon abuse/volume:** pre-parse caps, origin checks, per-adapter rate - limiting, sticky sampling, closed enums, capability-gated diagnostics. -- **ABI freeze risk:** versioned from day one; mixed-version delivery tested. -- **Ack protocol adds a message round-trip before `burl`:** bounded by the - existing renderer timeout; the `billed_then_failed` label measures the - policy. -- **Bootstrap replay:** own flag, replay-timing specs, staged rollout. -- **Schema generation:** checked-in artifacts + staleness CI. +Revision 3's list, plus: **shared-storage dependency for assets** (stage-1 +publish becomes a deploy prerequisite; mitigated by the embedded fast path +for the current vector and deploy-time verification that storage matches the +embedded hashes); **notification-trigger semantics** are now a published +contract for PBS-path demand — changing them later is a breaking change for +SSP reporting expectations. ## 12. Success criteria -1. APS creatives render on a reference page in each configured flow (SSAT, - Prebid adapter, page-bids), hermetically in CI and via the staged smoke - suite. +1. APS creatives render on a reference page in each configured flow, + hermetically in CI and via the staged smoke suite (including the real-GAM + overlap test). 2. Every failure point in section 2 maps to a distinct observable signal; diagnostic mode names the failing reason from one page load; production - telemetry meets the 5.4 SLO (≥ 1% failure modes visible within one hour). -3. Boundary lint passes with zero exceptions; stateful sharing only via the - versioned ABI; mixed-version delivery behaves as specified. + telemetry meets the 5.4 SLO on sink-backed deployments. +3. Boundary lint zero exceptions; stateful sharing only via the versioned + ABI; mixed-version delivery resolves to the G3 verdicts. 4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or generated. -5. Trace counts are per-impression; orphaned-slot recovery has a non-vacuous - test; `refresh_gen` attribution follows the G4a cycle protocol. +5. Trace counts are per-impression; orphan recovery has a non-vacuous test; + cycle attribution follows G4a including the publisher-overlap fail-closed + rule. 6. The only TSJS-owned global is `window.tsjs` (public globals only inside - their announced window, which closes per the 7.4 adoption gate); no - expandos on GPT slots, GPT functions, or `pbjs`. -7. Bundle budgets (raw/gzip/Brotli, +5% tolerance vs baseline) and the p90 - 20-run timing assertion hold; server-side per-request concatenation is - precomputed. -8. No existing warning is lost; every issue-surfacing condition logs at `warn` - or above with the beacon's reason code. -9. TypeScript floor matches the resolved 5.9 line with the strictness flags - on; `prebid.js` npm pin matches the documented deployed bundle; monthly - review policy in CI docs. -10. Rolling-deploy tests: old HTML always receives its exact old assets during - the retention window; unknown hashes 410 with `no-store`; immutable - caching only on exact matches. -11. `nurl` and `burl` fire on their distinct G4d transitions, idempotently, - and never before their triggering state. + their window, closed by the 7.4 adoption gate); no expandos on GPT slots, + GPT functions, or `pbjs`. +7. Bundle budgets (+5% bytes) and the pinned-environment p90 timing assertion + (50 samples, +10% tolerance) hold; server concatenation is precomputed + within ±10% CPU/heap of baseline. +8. No existing warning is lost; every issue-surfacing condition logs at + `warn` or above with the beacon's reason code. +9. TypeScript floor matches the resolved 5.9 line with strictness flags on; + `prebid.js` pin matches the documented deployed bundle; monthly review + policy in CI docs. +10. Rolling-deploy tests pass in both directions; legacy URLs serve through + their sunset; unknown hashes 410 `no-store`; immutable only on exact + match. +11. `nurl`/`burl` fire only on carrying paths, on their G4d transitions, + attempt-scoped and idempotent; APS fires neither. +12. Trace-bearing responses are `private, no-store` by test; capabilities are + per-trace and never authorize a trace they were not bound to. ## 13. Open questions @@ -812,7 +700,9 @@ PR-gating. 4. Is PR #997 the intended restoration of the lost #922 attribution core, or should the original be re-merged? 5. Do Axum/Cloudflare/Spin get real client-event sinks, or keep - accept-count-drop? (Datasource `ts_client_events`, 30-day retention, and - 10% default sampling are proposed values pending operator sign-off.) -6. Does Amazon expose any creative-completion acknowledgement we could adopt - to move APS beyond `render_accepted` (G4c)? + accept-count-drop? +6. Does Amazon expose any creative-completion acknowledgement that could + reintroduce a confirmed state (G4c) under a new name? +7. Which shared storage backs stage-1 artifact publishing per platform (KV + store vs config store vs CDN), and who owns the publish step in the deploy + pipeline? From 42a0bdc3a4e74229e6518b64e8caaec402676af8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:48:21 -0700 Subject: [PATCH 005/194] Rewrite design spec for the coordinated hard-cutover policy Revision 5 adopts the hard-cutover release model (exact release ids, config format_version, embedded-only artifacts with 410 on unknown hashes, final namespace at cutover, switch-and-purge runbook) and removes the N/N-1 machinery. It also makes the document fully self-contained and addresses the fourth review round: physical request-cycle protocol opened by slotRequested with intent separation, RuntimeSession-owned cycle state with quarantine and drain-by-match, propagation-stopping bridge order that preserves the stolen-capability defense, per-flow nurl bind definitions, a complete non-GPT direct /auction lifecycle, a fully inlined mediation algorithm with server- minted candidate ids and deterministic total ordering, a specified signed trace authorization (v1.kid.exp.mode.sig, HMAC-SHA-256, key rotation, server-decided sampling), wire events covering every G4c observation with concrete Tinybird schemas and bid_drop rows, sink dedup keys and numeric rate-limit bounds, early isolated internal routes on all adapters, a CSP rollout that cannot false-pass with both report media types and three-browser coverage, transactional plugin install with reverse-order unwind, the final global-surface table, refresh_gen-scoped token registries with capacity rules, structured dimension drops, generated inline validators, executable phase gates with control cohorts, and the expanded acceptance matrix with the section-2 failure-to-signal mapping. --- ...s-render-fix-and-tsjs-resilience-design.md | 1453 ++++++++++------- 1 file changed, 839 insertions(+), 614 deletions(-) diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index 9c53fca6a..4cf8c4d4d 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1,695 +1,921 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 4 — reworked after the third design review round. +- **Status:** revision 5 — rewritten for the coordinated hard-cutover policy + adopted in the fourth review round, and made fully self-contained (no + contract is defined by reference to an earlier revision). - **Date:** 2026-08-04 -- **Baseline:** `rc/july` @ `541298695` — the full merged state (everything - merged from `main` plus every rc-only merge), not just the delta pending - against `main`. -- **Inputs:** three code audits performed against this baseline; design - reviews of revisions 1–3; open issues #926, #941, #944, #962, #964, #977, - #983, #989, #993; open PR #997. -- **Terminology:** the per-refresh counter is `refresh_gen` everywhere. The - event previously named `render_confirmed` is removed (see G4c). - ---- +- **Baseline:** `rc/july` @ `541298695` — the full merged state. +- **Inputs:** three code audits against this baseline; design reviews of + revisions 1–4; open issues #926, #941, #944, #962, #964, #977, #983, #989, + #993; open PR #997. + +## 0. Release policy: coordinated hard cutover + +This design targets a **single coordinated release**. Explicitly: + +- Server, TSJS bundles, config format, and page HTML ship together as one + release with one **release id** (`release_id`: the git tag / build hash). +- **No N/N−1 support.** Old pages, old bundles, old config blobs, old + globals, and old URLs may stop working at cutover. In-flight clients (pages + loaded before the switch) may fail; this is accepted and stated, not + mitigated. +- **Exact release matching only.** The kernel, every service, every plugin, + and the install manifest carry the same `release_id`; any mismatch is a + refusal, never a negotiation. There are no version ranges. +- **Config format version:** the config blob gains a top-level + `format_version`; the binary rejects any other value. No + omit-default-for-rollback serialization rules; rollback means redeploying + the previous release with its own config. +- **Assets:** binaries embed only their own release's artifacts. Hashed + pathnames are kept **for cache identity only** (correct caching and + dedup), not for retention: an unknown hash answers `410 Gone`, + `Cache-Control: no-store`. No shared artifact store — no separate + requirement for one was established. +- **Cutover runbook (normative):** (1) deploy the release to all instances + dark (flagged off); (2) verify instance health and config + `format_version`; (3) switch traffic atomically at the routing/CDN layer; + (4) purge the CDN of prior HTML and assets; (5) monitor the Phase gates + (§8); rollback = switch traffic back to the previous release and re-purge. + +Everything that revisions 3–4 built for mixed-version tolerance is removed: +no ABI version ranges, no retained-artifact storage, no legacy query-hash +sunset, no dual-name global window, no adoption gates. ## 1. Problem statement -APS (Amazon Publisher Services) demand is fully integrated server-side — the -edge server runs the APS OpenRTB auction, wins bids, and ships a typed renderer -descriptor to the page — yet APS creatives still do not appear for real users. -Every previous fix (the `bid.meta` carrier, the decoupled prebid shim, the -`hb_adid` fallback) addressed a real defect, and APS still does not render. -That pattern is itself the finding: the APS pipeline has **multiple independent -failure points, most of which fail silently**, and the client library has **no -way to tell the server (or the operator) which one fired**. - -At the same time, the TSJS client library has grown organically to 56 files / -~11,900 lines with two ~1,700-line monoliths, duplicated logic maintained by -hand in two languages, inverted layering, and roughly one hundred `catch` -blocks that discard failures. The APS outage and the library's shape are the -same problem seen from two sides. +APS demand is fully integrated server-side — the edge runs the APS OpenRTB +auction, wins bids, and ships a typed renderer descriptor to the page — yet +APS creatives do not appear for real users. Serial single-cause fixes (the +`bid.meta` carrier, the decoupled prebid shim, the `hb_adid` fallback) each +survived review and still did not produce ads. That pattern is the finding: +the APS pipeline has **multiple independent failure points, most of which +fail silently**, and the client cannot tell the server which one fired. -This design covers both: (a) the specific fixes that make APS render, and (b) -the target architecture that makes TSJS a clean, resilient library. +The TSJS library (56 files, ~11,900 lines, two ~1,700-line monoliths, +duplicated ES5/TS logic, inverted layering, ~100 error-swallowing `catch` +blocks) is the same problem structurally. This design fixes APS delivery and +rebuilds TSJS so the next integration cannot reproduce this failure class. ### Non-goals - No change to the APS OpenRTB endpoint contract or Amazon-side configuration - (including its deliberate absence of `nurl`/`burl` — see G4d). -- No rewrite of Prebid.js integration strategy (the decoupled shim stays). -- No behavior change for publishers whose pages work today. Public-surface - migrations happen behind a bounded compatibility window (7.4), never by - immediate removal. - ---- - -## 2. Why APS still does not render — the evidence - -The audit traced all four delivery flows: (a) SSAT server-side ad template via -`window.tsjs.bids`, (b) GAM + client-side `trustedServer` Prebid adapter, (c) -SPA `/_ts/page-bids` re-auction, (d) direct `/auction` via `tsjs.requestAds`. -Only flow (d) — the demo path nobody runs in production — can render an APS -descriptor without GAM's cooperation. - -### 2.1 Admission: APS bids are eliminated before they can win - -| # | Failure | Where | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | -| A1 | **A configured `[auction].mediator` discards every direct-provider bid.** Winners come exclusively from the mediator response; APS bids (with their renderers) are used only as mediator input and reporting. APS shows `status: success, bid_count: N` yet never wins a slot. | `orchestrator.rs:412-431` | -| A2 | **`allow_script_creatives` defaults to `false`**, dropping every `tagtype: "script"` APS bid — a large share of TAM demand. The drop is counted but invisible (see A4). | `aps.rs:141-143`, `:773-778` | -| A3 | **Strict per-bid gates**: exact `w`×`h` membership in the slot's configured formats, required `ext.creativeurl`, and any top-level `contextual` key rejects the entire response. | `aps.rs:657-668`, `:745-778`, `:838-846` | -| A4 | **Drop reasons never reach an operator on the production paths.** `drop_reasons` counters surface only in `/auction` `ext.orchestrator`; the SSAT and page-bids paths discard them, server logs do not carry them, and the `ts-debug` comment allowlist excludes them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | - -### 2.2 Identity: the `hb_adid` contract with GAM is unproven - -| # | Failure | Where | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| B1 | **GAM key-value values are capped at 40 characters.** The new fallback emits the raw APS OpenRTB bid `id` (a long opaque string) as `hb_adid`. If GAM truncates or rejects it, `%%PATTERN:hb_adid%%` comes back different, the bridge's equality check fails, and it bails **with no log**. | `publisher.rs:3366-3372`, `gpt/index.ts:1613` | -| B2 | **Two id universes for the same bid.** SSAT keys the bridge on the APS bid id; the client-side Prebid adapter keys on Prebid's generated `adId`. A page running both paths registers the same slot under different ids. | `publisher.rs:3366`, `prebid/index.ts:982` | - -### 2.3 Render: the client has one narrow happy path and no fallback - -| # | Failure | Where | -| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| C1 | **If GAM never serves the Prebid Universal Creative, nothing renders and nothing is recorded.** The renderer descriptor sits unused in `window.tsjs.bids`; `renderApsCreative` is reachable only from the unused flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` | -| C2 | **A renderer endpoint that never answers is a silent 10-second death.** The sandboxed iframe cannot read an HTTP status from its opaque origin; a 404/401/misrouted document simply never posts `renderer-ready`. | `aps.rs:1188-1245`, `aps/render.ts:384-404` | -| C3 | **SafeFrame breaks slot attribution.** The bridge resolves a message source by walking top-document iframes under the slot div; a nested SafeFrame creative window is invisible to that walk, so the bridge bails silently. | `gpt/index.ts:157-183`, `:1599-1600` | -| C4 | **Three hand-maintained copies of the descriptor schema** (Rust struct, TS validator, inline renderer-document validator) with exact-key rejection: any server-side field addition instantly blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` | -| C5 | **A dead duplicate renderer branch in the bridge** contains the debug log and dedup logic people would look for while debugging; it can never execute. | `gpt/index.ts:1643-1674` | -| C6 | **The renderer CSP may kill creatives after success is reported** (`object-src`, workers, `blob:`/`data:` frames are blocked; `renderer-ready` fires before the creative actually paints). | `aps.rs:49` | -| C7 | **The renderer branches record nothing**: no `recordRender`, no win/billing beacons, no `stampCreativeTrace` — an APS win looks "never rendered" in every trace whether or not it painted. | `gpt/index.ts:1558-1632` | - -### 2.4 Observability: the common factor - -There is **zero client→server reporting**. Server telemetry marks `is_win=1` -at auction time and goes quiet; a bid that never painted is byte-identical to -one that painted perfectly. Client-side evidence dies with the tab. - ---- + (including its deliberate absence of `nurl`/`burl`, §G4d). +- No rewrite of the decoupled Prebid.js strategy. +- **No backward compatibility** (§0). Publisher-visible surfaces change at + cutover; the replacement shapes are in §7.4. + +## 2. Why APS does not render — evidence + +Flows: (a) SSAT via `window.tsjs.bids`; (b) GAM + client `trustedServer` +Prebid adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction` +`tsjs.requestAds`. Only (d) — unused in production — renders an APS +descriptor without GAM. + +### 2.1 Admission + +| # | Failure | Where | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| A1 | A configured `[auction].mediator` discards every direct-provider bid; winners come only from the mediator response. APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` | +| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; the drop is counted but invisible (A4). | `aps.rs:141-143`, `:773-778` | +| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:657-668`, `:745-778`, `:838-846` | +| A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and the `ts-debug` allowlist exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | + +### 2.2 Identity + +| # | Failure | Where | +| --- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| B1 | GAM caps key-value values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge equality check with no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1613` | +| B2 | Two id universes: SSAT keys on the APS bid id, the client adapter on Prebid's generated `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` | + +### 2.3 Render + +| # | Failure | Where | +| --- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` is reachable only from flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` | +| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1188-1245`, `aps/render.ts:384-404` | +| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:157-183`, `:1599-1600` | +| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` | +| C5 | A dead duplicate renderer branch holds the debug log and dedup logic; it can never execute. | `gpt/index.ts:1643-1674` | +| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` | +| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1558-1632` | + +### 2.4 Observability + +Zero client→server reporting. Server telemetry marks `is_win=1` at auction +time; a bid that never painted is byte-identical to one that painted. + +### 2.5 Failure → signal mapping (normative) + +Every section-2 failure maps to a distinct observable **failure class** (not +a claim to distinguish unknowable root causes): + +| Failure | Client event/reason (§5.1) | Server counter/row (§5.6) | Console | +| ------- | -------------------------------------------- | -------------------------------------- | ------------ | +| A1 | — | selection report `mediator_superseded` | startup warn | +| A2 | — | `bid_drop{script_rendering_disabled}` | startup warn | +| A3 | — | `bid_drop{invalid_dimensions, w, h}` | warn | +| A4 | — (fixed by §5.6 itself) | `bid_drop` rows exist on all paths | `ts-debug` | +| B1/B2 | `bridge_request{matched: false}` | join via `trace_id` | warn | +| C1 | `gam_empty` then no `bridge_request` | join via `trace_id` | warn | +| C2 | `render_fail{renderer_document_no_load}` | renderer route counters | warn | +| C3 | `render_fail{bridge_id_mismatch}` | join via `trace_id` | warn | +| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | warn | +| C5 | — (branch deleted) | — | — | +| C6 | `runner_failed` + CSP report buckets | CSP aggregate counters | warn | +| C7 | renderer branch emits the full §5.1 sequence | join via `trace_id` | debug/warn | ## 3. The GPT reality this design must respect -1. **Bootstrap-first hybrid:** the server injects a 495-line ES5 - `gpt_bootstrap.js` before the bundle; shared monkeypatch sentinels mean the - bundle's handoff and initial-load code is dead in production. -2. **The #922 merge loss:** orphan-slot recovery and `updateRender` enrichment - are gone (`0dc9b19a9`); `__tsRenderGeneration`/`__tsRenderBid` are dead - writes; bridge-served impressions double-count. PR #997 appears to be the - reworked replacement. -3. **TS refreshes never pass `changeCorrelator: false`.** -4. **`enableSingleRequest()` is called blind** after the publisher's own - `enableServices()` has almost always run. -5. Responsive resolution is a DOM-element-selection ladder; ambiguity silently - skips the slot. -6. Three independent wrappers on `pubads().refresh` coordinate via - window-global booleans. -7. **GPT offers no request cancellation** (`gpt/index.ts:1080`), and its event - contract identifies only the slot: Google documents no per-refresh - identifier and no completion-order guarantee for overlapping requests, and - `slotRenderEnded` means creative code was injected, not that its resources - loaded. Publisher code can refresh an adopted slot while a TS cycle is - pending. Any attribution scheme must survive all of that (G4a). -8. **The bundle's `slotRenderEnded` registration is gated behind - `!ts.servicesEnabled`** (`gpt/index.ts:1017`), so bootstrap-first or - services-enabled pages can miss the listener entirely — G4a requires - unconditional early subscription. - ---- - -## 4. Design gates — the five contracts +1. Bootstrap-first hybrid: server-injected ES5 `gpt_bootstrap.js` wins the + sentinel race; the bundle's handoff/initial-load code is dead in + production. +2. The #922 merge loss: orphan recovery and `updateRender` are gone + (`0dc9b19a9`); `__tsRenderGeneration`/`__tsRenderBid` are dead writes; + bridge impressions double-count. PR #997 is the apparent replacement. +3. TS refreshes never pass `changeCorrelator: false`. +4. `enableSingleRequest()` is called blind after publisher `enableServices()`. +5. Responsive resolution ambiguity silently skips slots. +6. Three independent `pubads().refresh` wrappers coordinate via window-global + booleans. +7. **GPT has no request cancellation, no documented per-refresh identity, no + overlapping-completion order.** `slotRenderEnded` means creative code was + injected, not that resources loaded. The April 2025 `responseIdentifier` + identifies the ad **response** — usable for response dedup/drain, not for + attributing which caller initiated a request. +8. **With initial load disabled, `display()` creates no request** — the + subsequent `refresh()` does (`gpt/index.ts:1059`, `ad_init.test.ts:1201`). + Any cycle protocol must model physical requests, not API calls. +9. The bundle's `slotRenderEnded` registration is gated behind + `!ts.servicesEnabled` (`gpt/index.ts:1017`); G4a needs unconditional early + subscription. + +## 4. Design gates ### G1 — Trace identity and correlation -**The client-visible auction id must never be ingested** (EC-derived: -`publisher.rs:3237`). **Initial-HTML auction telemetry is emitted before page -JavaScript exists** (`telemetry.rs:148`, `publisher.rs:2452`), so correlation -is minted by whoever acts first: - -- **Initial navigation (`nav_gen = 0`):** the server mints `trace_id` - (128-bit CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction - telemetry rows at emit time (a new nullable `trace_id` column on the - existing auction datasource; the independent telemetry `auction_id` UUID - remains and remains separate), and injects it into the page as a `tsjs` - boot field with the sampling decision and, when the tester gate is active, - the diagnostic capability (5.3). -- **Cache-privacy invariant:** a trace or capability is injected **only** into - responses that ran a per-request auction, and any trace-bearing HTML MUST be - shared-cache-ineligible: `Cache-Control: private, no-store` and no - validators that could revalidate a shared copy. This is enforced by - construction (the injection site is the auction-bearing render path) and by - test. A shared-cached page would have no per-visitor auction to correlate - anyway; the invariant makes that alignment explicit. -- **SPA navigations (`nav_gen > 0`):** `/_ts/page-bids` **stays GET** (it is - GET in `publisher.rs:3815` and the client issues GET at - `gpt/index.ts:1152`; a browser GET cannot carry a body). The client mints - the `trace_id` and sends it in a validated **`X-TSJS-Trace-Id`** request - header — the request already carries a non-simple TSJS header, so CORS - preflight behavior is unchanged. The server records it contemporaneously in - that auction's telemetry rows and the JSON response **echoes the accepted - trace id** and returns a capability bound to it when the tester gate is - active. -- **Envelope:** every event carries - `{trace_id, sampled, nav_gen, refresh_gen, seq}`; `seq` is per-trace - monotonic. -- **Sampling is trace-sticky** (decided once per trace; server-decided for - `nav_gen 0`, client-decided from the injected rate afterwards). Transport is - best-effort, so a sampled trace may still arrive partial; partiality is - detectable via `seq` gaps and is not treated as a contract violation. - -### G2 — Render identity: `hb_adid`, the APS token, and the PBS Cache UUID - -Unchanged from revision 3 (review-accepted): cache-backed bids keep the cache -UUID as `hb_adid` byte-for-byte; renderer-only bids get a server-minted token -`^[a-z0-9]{12}$` (CSPRNG; in-auction collision retry; cross-auction uniqueness -probabilistic with the birthday bound documented; harmless via per- -`(trace_id, nav_gen)` registry scoping); TTL 15 minutes; one-time consumption; -client-Prebid keeps Prebid's `adId`; non-APS cache-path regression tests. - -### G3 — Runtime ABI: how code shares state under the IIFE build +The client-visible auction id is EC-derived (`publisher.rs:3237`) and is +never ingested. Initial-HTML auction telemetry is emitted before page JS +exists (`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by +whoever acts first: + +- **Initial navigation (`nav_gen 0`):** the server mints `trace_id` (128-bit + CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction rows + (§5.6 schema), and injects it into `tsjs.boot` with a **signed trace + authorization** (§5.3). It is never `AuctionRequest.id`. +- **Cache privacy invariant:** traces/authorizations are injected only into + responses that ran a per-request auction; such HTML is + `Cache-Control: private, no-store` with no validators. Enforced by + construction and by test. +- **SPA navigations:** `/_ts/page-bids` stays GET; the client mints + `trace_id` and sends it in a validated `X-TSJS-Trace-Id` header (the + request already carries a non-simple TSJS header). The server records it in + that auction's rows; the JSON response echoes the accepted trace and + returns its signed authorization. +- **Envelope:** every event carries `{nav_gen, refresh_gen, seq}` inside a + per-trace group `{trace_id, auth, events[]}` (§5.1). `seq` is per-trace + monotonic. Transport is best-effort: gaps (loss) and duplicates + (fetch/pagehide races) are both expected; §5.5 defines dedup. +- **Sampling is server-decided for every trace** — initial via boot, SPA via + the page-bids response — and carried **inside the signed authorization** + (§5.3 `mode`). The client never asserts its own sampling; an unsigned or + missing authorization means the trace group is rejected at ingest. + +### G2 — Render identity + +- Cache-backed bids: `hb_adid` = the PBS Cache UUID, byte-for-byte as today + (`publisher.rs:3355`; the PUC fetches `?uuid=`, + `publisher.rs:3450`, `gpt/index.ts:1700`). Markup bids without cache ids: + today's fallback chain unchanged. +- **Renderer-only bids: `hb_adid` = a server-minted render token**, + `^[a-z0-9]{12}$` exactly, CSPRNG, collision-retried within the minting + auction. Cross-auction uniqueness is probabilistic (36¹² ≈ 4.7×10¹⁸; + birthday-bound negligible at realistic volumes) and made harmless by + registry scoping. +- **Registry scope and bounds:** the client bridge registry keys tokens by + `(trace_id, nav_gen, refresh_gen)` — refresh auctions within one + navigation cannot collide, closing the revision-4 gap. Capacity: 64 live + entries per navigation; at capacity a new registration is refused with + disposition `registry_full`; unexpired entries are evicted only by + navigation disposal. Token TTL 15 minutes; one-time consumption. +- The client-Prebid path keeps Prebid's generated `adId`; both paths register + into the one registry keyed by whichever id that path observes. +- Regression tests: non-APS cache-backed bids byte-identical. + +### G3 — Runtime ABI under the IIFE build (exact-release model) IIFE-per-bundle with inlined imports (`build-all.mjs:46`, `bundle.rs:23`) -means module imports never share state across bundles (live proof: +means imports never share state across bundles (live defect: `core/context.ts:11` vs `permutive/index.ts:102`). -Contract — a versioned registration ABI on `window.tsjs._internal`: - -- Kernel ships only in `tsjs-core`, publishes - `tsjs._internal = { abi: 1, registry }` once (window sentinel). The kernel - constructs and registers core service instances during boot; integrations - register integration-scoped services during `install()`. -- **Version semantics (settling the mixed-version gap):** every service - registers with `(major, minor)`. `registry.get(name, {major, minMinor})` - succeeds iff an implementation with the same `major` and `minor ≥ minMinor` - is registered. The install manifest's plugin versions are **ranges with the - same semantics** (required major, minimum minor), not exact pins. - An incompatible service registration is **quarantined** — recorded, not - installed — and surfaced as `abi_mismatch`; an incompatible plugin as - `bundle_partial`. **First-wins applies only among compatible - registrations.** The old-deferred-bundle + new-core scenario therefore has a - deterministic verdict: the old plugin either satisfies the manifest range - and runs, or is quarantined loudly. -- Stateful services only via the registry at call time; stateless helpers may - be imported and inlined. Single-module-graph builds remain the successor - option behind the same surface. +- The kernel ships only in `tsjs-core`, publishes + `tsjs._internal = { release_id, registry }` once (window sentinel), and + **freezes** `_internal` after boot. The kernel constructs and registers + core services (event bus, beacon queue, sessions, slot registry, render + state machine) during boot; integrations register integration-scoped + services during `install()`. +- **Exact release matching:** every service and plugin registration carries + `release_id`; `registry.get(name)` succeeds only when the registrant's + `release_id` equals the kernel's. A mismatch quarantines the registration + and emits `abi_mismatch` (service) / `bundle_partial` (plugin) with a + console error. No ranges, no minors, no first-wins tiers — under §0 a + mismatch is a deployment error to surface, not tolerate. +- Stateful access only through the registry at call time; stateless helpers + may be imported and inlined. Single-module-graph builds remain the + recorded successor option behind the same surface. ### G4 — Render lifecycle -**G4a — Request-cycle protocol (no ordering assumptions).** GPT documents no -per-refresh identity and no completion-order guarantee, so the protocol -assumes neither: - -- Every observable request initiation is classified `ts | publisher`: TS's own - `display()`/`refresh()` calls open TS cycles; the wrapped publisher - entry-points and `slotRequested` events that match no TS cycle are recorded - as publisher-initiated. -- **TS serializes itself to at most one outstanding cycle per slot** — a new - TS refresh for a slot with a pending cycle waits or supersedes explicitly; - it is never concurrently pending. -- With ≤1 TS cycle outstanding, a `slotRenderEnded` is attributable iff no - untracked or publisher-initiated request overlaps it. **Any overlap marks - the slot `cycle_unattributable` and fails closed** (no fallback, no state - transition, console warning + disposition). -- `slotRenderEnded` is treated as "creative code injected", not "resources - loaded" — it can confirm delivery, never paint. -- The deterministic PUC/message harness exercises the protocol in CI, and a - **release-gating real-GAM overlap test** (publisher refresh racing a TS - cycle) validates the contract against actual GPT, since a FIFO-assuming - stub proves nothing. - -**G4b — Acknowledgement path.** Unchanged from revision 3 (review-accepted): -per-attempt CSPRNG nonce in the bridge response; the dynamic renderer posts -versioned accepted/failed messages to the kernel; the kernel validates source -ownership, nonce, token, `nav_gen`, `refresh_gen` before any transition or -callback; pinned for SSAT, client-Prebid, and nested SafeFrame flows. - -**G4c — Honest observations, `render_confirmed` removed.** The inline-adm -frames are sandboxed `srcdoc` documents with `allow-same-origin` deliberately -omitted (`gpt/index.ts:358`) — their origins are opaque, so revision 3's -"same-origin observable" premise was false. The event is removed entirely. -The taxonomy is now: `gam_nonempty`, `gam_empty`, `renderer_document_loaded`, -`runner_loaded`, `runner_failed`, `adm_document_loaded` (the iframe `load` -event for TS-written adm frames — document delivery, not paint). **Every -render path terminates at `render_accepted`** (authenticated per G4b where -the renderer protocol exists; `adm_document_loaded` for adm frames). No -observation claims paint. A future trusted completion acknowledgement (open -question 6) may reintroduce a confirmed state under a new name. - -**G4d — Win/billing notifications, scoped to paths that have them.** APS -**intentionally carries neither** `nurl` nor `burl` (`aps.rs:812` sets both -`None`; the minimized AAX envelope excludes notifications; the integration -guide documents that generic win/billing beacons are not fired for APS). APS -billing runs entirely inside the Amazon runner lifecycle, and this design -does not change the APS wire contract. - -For bid paths that do carry the URLs (PBS and other OpenRTB providers): - -- **Trigger semantics, published explicitly:** `nurl` fires when the render - attempt binds the bid to a cycle (Trusted Server's selection produced the - candidate GAM will render — the earliest point at which "win" is - meaningful for this pipeline); `burl` fires at the attempt's - `render_accepted`. Both are **attempt-scoped**, keyed by - `(trace_id, nav_gen, slot, refresh_gen, hb_adid)` as the idempotency key — - fired at most once per attempt, not page-wide. -- **Owner and mechanics:** the client render pipeline owns firing (as today, - `gpt/index.ts:459`), via `sendBeacon`/`no-cors fetch`, no retries (a beacon - either queues or is lost; retrying risks double-billing). -- Terminal failure after acceptance is labeled `billed_then_failed`; no - un-firing. - -**G4e — Fallback trigger.** The opt-in fallback -(`[auction].client_render_fallback = "renderer"`) renders only after a -**terminal `gam_empty` unambiguously attributed to a TS-initiated cycle** -(G4a). Timeouts are diagnostics-only and never render. Revision 3 disabled -fallback for adopted slots entirely, which — as the review noted — excludes -the common production path (pre-existing publisher slots are adopted and -refreshed, `gpt/index.ts:925`). Revised: **ownership does not gate the -fallback; attribution does.** An adopted slot whose attributed TS cycle ends -in `gam_empty` may fall back; any publisher-initiated or unattributable cycle -never triggers it. The success criteria and browser specs cover the adopted -case explicitly. - -### G5 — Deployment contracts - -- **Config rollback:** new config fields are default-valued and omitted from - serialization at defaults. **Rollback runbook rule:** after an operator has - opted into a new field, rolling the binary back requires restoring the - default and pushing the default-compatible blob first (this mirrors the - project's existing rollback guidance). -- **Asset identity and the artifact source (settling "not realizable"):** - hash in the pathname; and artifacts are **published to shared immutable - platform storage (KV/config store) as deploy stage 1, before any HTML - references them** — binaries serve the current vector from embedded bytes - (fast path) and everything else by hash lookup in shared storage. This - answers all four skew cases: new HTML hash `B` reaching an old instance - (lookup serves `B` from storage), a miss for retained `A` after only `B` is - embedded (lookup), already-issued legacy query-hash URLs (the legacy path - keeps serving current bytes with short-TTL, non-immutable caching through a - documented sunset), and renderer `/v2` reaching a `/v1`-era instance - (versioned renderer documents are published to the same storage in - stage 1). Two-stage deployment is the contract: **stage 1 publish - artifacts, stage 2 roll binaries/HTML.** Both rolling directions and the - legacy URL are tested. Retention: ≥ 7 days, which must exceed the HTML - cache lifetime — itself now bounded by contract (auction-bearing HTML is - `no-store` per G1; any cacheable non-auction HTML referencing tsjs sets - `max-age ≤ 300`). `Cache-Control: immutable` only on exact hash matches; - unknown hashes → `410 Gone`, `no-store`. Concatenations are keyed by the - **ordered module-ID vector**, precomputed in Phase 0 (which owns asset - identity). -- **Ingest routing:** the beacon route exists in all four adapters as an - early, EC-free, filter-free route; only Fastly has a real sink; others - accept-count-drop by explicit contract. -- **Storage:** datasource `ts_client_events`; retention 30 days; production - sampling 10%. These are **adopted defaults** (operator-tunable), no longer - open questions; the remaining open question is only whether non-Fastly - adapters get sinks (OQ5). -- **Phase gates are phase-specific** (section 8) — the render-fail canary - applies only from Phase 3 onward, because earlier phases don't create that - metric. - ---- - -## 5. Workstream 1 — Observability - -### 5.1 Event payload — minimized, grouped per trace +**G4a — Physical request-cycle protocol.** Two separated notions: + +- **Intent:** a TS `display()`/`refresh()` call (or an observed publisher + entry) targeting a slot. Intents are classified `ts | publisher` at the + wrapped entry points. An intent may produce zero physical requests + (initial-load-disabled `display()`; `refresh()` on a never-displayed + adopted slot); an intent that produces no `slotRequested` within its bound + (2 s) expires with disposition `intent_no_request` — diagnostics only. +- **Cycle (outstanding physical request):** opened **only by + `slotRequested`**, matched to the oldest unexpired TS intent for that slot, + else classified publisher-initiated. SRA batching yields one `slotRequested` + per slot per batch — one cycle each, all matched to the intents of the + batch call. A cycle closes on its `slotRenderEnded` (matched by slot; GPT's + `responseIdentifier`, where present, deduplicates responses during drain — + it never attributes initiation). +- **Serialization:** TS keeps at most one outstanding TS cycle per slot. A TS + intent arriving while a TS cycle is outstanding **queues** (bounded: 1 + queued replacement; further intents coalesce into it). +- **Attribution:** a `slotRenderEnded` is attributable iff exactly one + TS cycle is outstanding for the slot and no publisher-initiated or + untracked request overlaps it. Any overlap → the slot enters + **quarantine**: `cycle_unattributable`, fail closed (no fallback, no state + transition), and the drain rule applies. +- **Drain/re-arm (supersession and SPA):** physical cycle state lives in the + **RuntimeSession-owned slot record**, not the NavigationSession — adopted + slots outlive navigations. On navigation or supersession, outstanding + cycles are marked stale; their late events are **matched and discarded** + with disposition `stale_navigation` (never misattributed); a quarantined or + stale slot re-arms only after every outstanding request/render pair has + drained (or its 60 s drain bound elapses, which keeps the slot + fallback-ineligible for that navigation). Queued TS intents dispatch only + after re-arm. A timeout never makes an old event disappear — drain-by-match + does. +- CI exercises the protocol on the deterministic harness; a **release-gating + real-GAM overlap test** (publisher refresh racing a TS cycle; + initial-load-disabled cycle formation) validates it against actual GPT. + +**G4b — Acknowledgement protocol.** The renderer document currently posts +"ready" only to its immediate parent (`aps.rs:105`); in the PUC path the +top-level kernel cannot observe it, and callbacks fire on send +(`gpt/index.ts:1572`, `:1620`). Contract: the bridge response embeds a +**per-attempt 128-bit CSPRNG acknowledgement nonce**; the renderer document +posts versioned `{t: "render_accepted" | "render_failed", nonce, reason?}` +to the top window; the kernel validates, in order: source ownership (§6.8 +walk), nonce equality, token binding, `nav_gen`, `refresh_gen` — all five — +before any state transition or notification. Pinned by tests for SSAT, +client-Prebid, and nested SafeFrame flows, including stale and replayed +acks. + +**G4c — Honest observations.** Inline-adm frames are sandboxed `srcdoc` +without `allow-same-origin` (`gpt/index.ts:358`) — opaque origins; geometry +proves nothing. Observations: `gam_nonempty`, `gam_empty`, +`renderer_document_loaded`, `runner_loaded`, `runner_failed`, +`adm_document_loaded`. Every path terminates at `render_accepted` +(authenticated per G4b where the renderer protocol exists; +`adm_document_loaded` stands in for adm frames). **No observation claims +paint**; there is no `render_confirmed`. A future trusted completion ack +(OQ6) may add a new state under a new name. + +**G4d — Win/billing notifications.** APS intentionally carries neither +`nurl` nor `burl` (`aps.rs:812`; the minimized AAX envelope excludes them; +the integration guide documents no generic APS beacons). APS billing lives in +the Amazon runner lifecycle; unchanged. + +For carrying paths (PBS and other OpenRTB providers), **bind is defined per +flow and is never selection or targeting** (targeting-only firing is +explicitly prevented today, `ad_init.test.ts:1824`): + +- PUC/GAM flow: bind = an owned, slot-and-ad-id-matched bridge claim. +- Direct `/auction` flow: bind = validated render start (slot resolved, + descriptor/markup validated, attempt created). +- Fallback flow: bind = attributed `gam_empty`, immediately before the + fallback render starts. + +`nurl` fires at bind; `burl` at `render_accepted`; both attempt-scoped +(idempotency key `(trace_id, nav_gen, slot, refresh_gen, hb_adid)`), fired at +most once, via `sendBeacon`/`no-cors fetch`, no retries. Terminal failure +after acceptance → `billed_then_failed` label; no un-firing. + +**G4e — Fallback trigger.** Opt-in +(`[auction].client_render_fallback = "renderer"`). Renders only after a +terminal `gam_empty` **unambiguously attributed to a TS cycle** (G4a) — +ownership does not gate it (adopted slots are the common path and are +eligible); publisher-initiated or unattributable cycles never trigger it; +timeouts never render. The direct renderer is converted to an awaitable API +with cancellation and terminal reasons before the fallback lands. + +**G4f — Direct `/auction` lifecycle.** The non-GPT path +(`core/request.ts:52`) gets the same discipline: a `RenderAttempt` keyed +`(trace_id, nav_gen, refresh_gen, slot)` where `refresh_gen` increments per +`requestAds` invocation for the same slot within a navigation; exactly-once +terminal state; G4b acknowledgement validation; G4d direct-flow bind; +cancellation and disposal on navigation; the same §5.1 event sequence. "Every +configured flow" in the success criteria includes this one. + +### G5 — Deployment contracts (hard cutover) + +- **Config:** top-level `format_version`; exact match required; mismatch is a + startup error. No default-omission rollback rules. +- **Assets:** hash-in-pathname (`/static/tsjs//.js`) for cache + identity; binaries serve only embedded current-release artifacts; unknown + hash → `410 Gone`, `no-store`; `Cache-Control: immutable` on exact matches. + Concatenations precomputed per **ordered module-ID vector** at build time. + The cutover runbook (§0) owns HTML/asset consistency; the CDN purge step is + what retires old references. +- **Internal route isolation (all adapters):** the renderer, client-events, + and CSP-report route families (a) dispatch **before** auth, EC setup, and + publisher/integration filters (today the renderer can traverse EC setup and + pre-route filters, `app.rs:709` in the Fastly adapter); (b) reserve **all + methods and all version prefixes** locally — unsupported method → + deterministic `405` with `Allow` and `no-store`; unknown version → `404` + `no-store`; never the publisher fall-through some adapters use today + (`adapter-spin app.rs:804`); (c) never forward bodies, cookies, or + authorization headers to publisher origins; (d) compare origins as + normalized scheme + host + port, not host-only. +- **Ingest routing:** client-events in all four adapters; Fastly has the real + sink; others accept-count-drop by contract (OQ5). +- **Storage:** §5.6 schemas deploy and validate **before** any writer + enables. + +## 5. Observability + +### 5.1 Wire payload ``` { v: 1, traces: [ - { trace_id, sampled, capability?, // capability: only in diagnostic mode + { trace_id, auth, // auth: signed authorization (§5.3) events: [ { nav_gen, refresh_gen, seq, t: "bid_received" | "targeting_set" | "bridge_request" | "bridge_response_sent" | "render_attempt" | "render_accepted" | - "render_fail", - slot, // configured slot id if in the injected set, else "s" - id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none" - matched, // bridge_request only - source, // "renderer" | "adm" | "pbs-cache" | "gam" - reason, // render_fail only: closed enum below - width, height } // invalid_dimensions context only: bounded ints [0, 8192] + "render_fail" | "gam_nonempty" | "gam_empty" | + "renderer_document_loaded" | "runner_loaded" | "runner_failed" | + "adm_document_loaded" | "fallback_start", + slot, // configured slot id if in the injected set, else "s" + id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none" + matched, // bridge_request only + source, // "renderer" | "adm" | "pbs-cache" | "gam" + reason } // render_fail only ] } ] } ``` -- **Events are grouped per trace, and the diagnostic capability is a per-trace - field** — a navigation-spanning batch carries one group per trace, so one - batch-level capability can never be ambiguous, and an initial-navigation - capability never authorizes a client-minted SPA trace (that trace's - capability comes from the page-bids response, G1). -- Reason enum (closed; no interpolation — dimension context travels in the - bounded numeric fields): `renderer_document_no_load`, `runner_no_load`, - `runner_failed`, `descriptor_invalid`, `invalid_dimensions`, - `bridge_id_mismatch`, `cycle_unattributable`, `bridge_claim_timeout`, - `gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, - `pbjs_absent`, `bundle_partial`, `fallback_cancelled`, `abi_mismatch`. +The `t` enum now **contains every G4c observation**, so Phase-3 stage rates +(renderer-document load rate, runner load/failure, GAM fill) are queryable. +Reason enum (closed): `renderer_document_no_load`, `runner_no_load`, +`runner_failed`, `descriptor_invalid`, `invalid_dimensions`, +`dimensions_out_of_range`, `bridge_id_mismatch`, `cycle_unattributable`, +`intent_no_request`, `stale_navigation`, `bridge_claim_timeout`, `gam_empty`, +`no_render_source`, `slot_unresolved`, `gpt_absent`, `pbjs_absent`, +`bundle_partial`, `fallback_cancelled`, `abi_mismatch`, `registry_full`. ### 5.2 Transport -`fetch(..., {keepalive: true, credentials: "omit"})` primary. The `pagehide` -fallback is `navigator.sendBeacon(url, new Blob([json], {type: -"application/json"}))` — the Blob type satisfies the ingest media-type -contract (a bare string would arrive as text); it is credentialed by platform -design and its `true` means queued, not received; the handler ignores -credentials either way. Flush on `visibilitychange`/`pagehide` and every 5 s. - -### 5.3 Ingest wire contract - -- `POST /_ts/client-events` in all four adapters, before auth/EC/filters. - `Content-Type: application/json` only; no `Content-Encoding`. Responds - `204 Cache-Control: no-store`; never echoes input. +`fetch(..., {keepalive: true, credentials: "omit"})` primary; `pagehide` +fallback `navigator.sendBeacon(url, new Blob([json], {type: +"application/json"}))`. Flush every 5 s and on `visibilitychange`/`pagehide`. +**Client queue bound:** 256 events; overflow drops oldest, increments a +counter, and the final flushed batch carries one `render_fail{...}`-class +overflow marker event so truncation is visible. Duplicates from +fetch/pagehide races are expected and handled at the sink (§5.5). + +### 5.3 Signed trace authorization + +Format `v1....`: + +- `kid`: key id; **active and previous keys** live in the platform secret + store; rotation = introduce new key as active, demote, retire. +- `exp`: unix epoch seconds; verifier allows ±60 s skew; maximum future + 15 minutes from issuance. +- `mode`: `sampled` | `diagnostic`. Sampling is server-decided (G1); + diagnostic is a distinct authenticated mode gated by the tester cookie at + issuance — not an overloaded "sampling off" bit. +- `sig`: HMAC-SHA-256 over the **domain-separated, length-prefixed** input + `"ts-trace-auth-v1" || len(origin) || origin || len(trace_id) || trace_id +|| len(mode) || mode || u64(exp)`, where `origin` is the externally visible + scheme+host+port. Constant-time comparison. +- Ingest verifies per trace group; a missing key id, expired, future-dated, + or invalid signature → that **group** is dropped-and-counted (other groups + in the batch survive). An unsigned `sampled` claim does not exist in the + wire format, so it cannot be asserted. + +### 5.4 Ingest contract + +- `POST /_ts/client-events`; `Content-Type: application/json` only; no + `Content-Encoding`; responds `204`, `no-store`; never echoes input. - Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars; - `trace_id ^[0-9a-f]{32}$`; integers in `[0, 2³¹)`; width/height in - `[0, 8192]`. Violation → drop-and-count with `204`. -- Same-origin: `Sec-Fetch-Site: same-origin` when present, else `Origin` - matching the serving host; **absent both → drop-and-count**. -- **Rate limiting fails closed for telemetry:** when the limiter denies, or - when a portable adapter's best-effort limiter is unavailable or errors, the - request is dropped early with `204` (count only, no parse, no sink). Ad - delivery is unaffected by construction because this route serves nothing. -- **Trusted client address, per adapter, concretely:** Fastly — the - platform's client IP API; Axum — the rightmost `X-Forwarded-For` entry - beyond `trusted_proxy_hops` (a required config value when the beacon is - enabled; without it, the socket peer address is used and forwarded headers - are ignored); Cloudflare — `CF-Connecting-IP`; Spin — the platform client - address. Spoofable headers are never trusted beyond the configured hop - count. -- **Diagnostic capability:** server-issued, HMAC over - `trace_id + expiry` (≤ 15 min), delivered via the G1 boot field (initial - trace) or the page-bids response (SPA traces), echoed per trace group. - Signature verification is what switches a trace to unsampled — a public - query flag alone never does. - -### 5.4 Two modes, honestly separated - -- **Production telemetry** (sink-backed deployments only — today Fastly): - sticky-sampled (10%). SLO, fully parameterized: a failure mode affecting - ≥ 1% of **sampled render attempts** is visible in `ts_client_events` within - one hour, evaluated only when the deployment produced ≥ 10,000 sampled - render attempts in that hour, with sink ingestion freshness ≤ 5 minutes; - sink outages pause the SLO clock and are alarmed separately. Non-sink - adapters are explicitly out of SLO scope, and no global gate depends on - their beacon data. -- **Diagnostic mode:** capability-gated, unsampled, full stream + console - mirroring — the "one page load names the failing reason" tool. - -### 5.5 Server-side drop-reason surfacing - -- Bounded structured summary whenever **any** bid is dropped (per-slot reason - counts, capped); `drop_reasons` added to auction telemetry rows; drop - summary in the initial-HTML `ts-debug` comment; `/_ts/page-bids` gains a - tester-gated structured `debug` field. -- Startup warnings: APS enabled with `allow_script_creatives = false`; direct - provider configured alongside a mediator without 6.1's merge strategy. - ---- - -## 6. Workstream 2 — APS delivery fixes - -### 6.1 Mediation: opt-in merge, `mediator_only` stays the default - -As revision 3 (review-accepted), with one tightening: a mediator bid whose id -the provenance map cannot resolve, **for a slot where the same provider had -forwarded candidates**, is counted under a dedicated -`mediator_provenance_unresolved` metric and logged at `warn` — surfacing -possible self-competition instead of silently treating it as distinct demand. -Deal priority remains out of scope (the `Bid` model carries no deal identity; -recorded as follow-up). Currency mismatch remains rejection. One selection -helper serves both mediation lifecycles, with tests for each. - -### 6.2 Dimensions: the contract is "request what you accept" - -Exact size membership stays. The fix is visibility plus configuration: the -drop summary names the rejected size per slot via -`reason: invalid_dimensions` with bounded numeric `width`/`height` fields -(closed enum preserved), and documentation gains "sizing your slots for APS." + `trace_id ^[0-9a-f]{32}$`; integers in `[0, 2³¹)`. +- Same-origin: `Sec-Fetch-Site: same-origin` when present, else normalized + `Origin` equality; absent both → drop-and-count. +- **Rate limiting (numeric, fail-closed for telemetry):** token bucket per + client address, **10 requests/min, burst 20**; limiter map ≤ 65,536 + entries, entry TTL 10 min, LRU eviction; limiter unavailable/errored → + drop early with `204` (count only). Trusted client address per adapter: + Fastly — platform client IP; Axum — rightmost `X-Forwarded-For` entry + beyond required `trusted_proxy_hops` (absent config → socket peer only); + Cloudflare — `CF-Connecting-IP`; Spin — platform client address. + +### 5.5 Sink and deduplication + +- Stable event key `(publisher, trace_id, seq)`; deduplication at the sink + or query layer (Tinybird: latest-write or `GROUP BY` on the key), covering + fetch/pagehide double-delivery. +- The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`) and + cannot observe downstream schema/auth rejection — therefore + **datasource-side freshness monitoring is mandatory** (§8 gates alarm on + ingestion lag and row-rejection metrics from the datasource side). + +### 5.6 Physical schemas (deployed before writers) + +- **New datasource `ts_client_events`** (flattened rows): + `ts (DateTime64), publisher (LowCardinality String), release_id (String), +trace_id (FixedString 32), mode (Enum sampled|diagnostic), nav_gen UInt32, +refresh_gen UInt32, seq UInt32, event (Enum §5.1), slot (String ≤64), +id_kind (Enum), matched (UInt8), source (Enum), reason (Enum §5.1)`. + Sorting key `(publisher, ts, trace_id, seq)`; 30-day TTL; its own ingest + token, configured via new `TinybirdSettings` fields + (`client_events_dataset`, `client_events_token_secret`) — today's settings + configure only the auction dataset (`settings.rs:1752`). +- **Auction rows** (`AuctionEventRow`, `telemetry.rs:262`, and + `auction_events_raw.datasource`): add nullable `trace_id (FixedString 32)` + and `mode`; add a bounded **`bid_drop` row type** + `{provider, slot, reason (Enum), width UInt16?, height UInt16?, count +UInt32}` with per-auction row cap 32 and an `overflow` bucket row. +- APS parsing returns a **structured drop observation** + `{reason, slot, width?, height?}` instead of a bare reason string + (`aps.rs:722`); dimensions above 8192 use `dimensions_out_of_range` with + dimensions omitted, never clamped. + +### 5.7 Modes and SLOs + +- **Production (sink-backed only):** server-decided 10% sampling. + Two separated objectives: **pipeline availability** — ingestion freshness + ≤ 5 min and datasource rejection rate < 0.1%, alarmed on breach (fails + during sink outages, by design); **failure detection** — a failure mode + affecting ≥ 1% of sampled render attempts is visible within one hour, + evaluated only at ≥ 10,000 sampled render attempts/hour. +- **Diagnostic:** authenticated mode (§5.3), unsampled, full stream + + console mirroring; one page load names the failing class. + +### 5.8 Server-side drop surfacing + +Bounded structured summary whenever any bid is dropped; `bid_drop` rows +(§5.6); drop summary in the initial-HTML `ts-debug` comment; page-bids gains +a tester-gated structured `debug` field. Startup warnings: APS + +`allow_script_creatives = false`; mediator + direct providers without an +explicit `winner_selection` (§6.1 makes that a hard error). + +## 6. APS delivery fixes + +### 6.1 Mediation: complete inline algorithm + +Current mediation cannot support merging: it forwards no stable candidate id; +restores fields via a lossy last-write-wins `(provider, slot, bidder)` index +(`adserver_mock.rs:95`); breaks equal-price ties by response arrival order +(`orchestrator.rs:827`); and assigns parsed Prebid bids USD without +validating response currency (`prebid.rs:2318`). The algorithm below replaces +that, identically in the synchronous and split dispatch/collect paths, via +one shared candidate-selection helper. + +1. **Candidate registration.** Every direct-provider bid admitted by parsing + becomes a candidate with a **server-minted candidate id** (`c` + + 11-char CSPRNG, unique per auction). The full candidate (renderer, cache + coordinates, notification URLs, currency, provenance + `(provider, upstream_bid_id)`) is stored by candidate id. Winners are + selected **by candidate id** and their fields read from the stored + candidate — the lossy index is deleted. +2. **Currency.** The auction has one configured currency. A provider response + that declares another currency, or a path that cannot prove its currency + (the Prebid parse point must validate, not assume USD), rejects that bid + at parse with `bid_drop{currency_mismatch}`. No conversion. +3. **Mediator exchange.** Forwarded candidates carry their candidate id; the + mediator is required (wire contract, including `adserver_mock`) to echo it + on any bid derived from a forwarded candidate. A mediator bid **without** + an echoed id is mediator-native. A mediator bid with an id that does not + resolve → **the slot fails closed** for merging + (`mediation_provenance_invalid`: mediator-native bids for that slot still + compete; unresolvable forwarded claims are discarded and counted — a + warning alone is insufficient). +4. **Floors.** Slot floors filter both populations before selection. +5. **Dedup.** A mediator bid that echoes candidate id X removes direct + candidate X from the pool (it is the same demand, provenance `mediator`). +6. **Selection.** Per slot, the winner is the maximum under the **total + deterministic order**: decoded CPM desc → provenance rank (mediator + before direct) → provider name asc → candidate id asc. Response arrival + order can never matter. +7. **Strategy config.** `[auction].winner_selection` is **required whenever a + mediator and direct providers coexist** — startup error if absent (no + silent default; §0 removes the compatibility rationale for one): + `mediator_only` (mediator bids only, direct providers are signal) or + `merge_highest_cpm` (the algorithm above). A mediator timeout degrades to + direct-only selection and is reported. +8. **Reporting.** A selection report per auction: `winner_source`, + `mediator_superseded`, `currency_mismatch`, `dedup_hits`, + `mediation_provenance_invalid` — separate from delivery `bid_drop` rows. + +Deal priority remains out of scope: the `Bid` model carries no deal identity +(`types.rs:231`); a rule the model cannot express would be fiction. Recorded +as follow-up requiring a bid-model extension. + +### 6.2 Dimensions + +Exact size membership stays (`aps.rs:657-668`). The fix is visibility +(structured `bid_drop{invalid_dimensions, w, h}`, §5.6) plus documentation +("sizing your slots for APS"): if a size is acceptable, request it in the +slot's `formats` — accepting unrequested sizes would conceal an upstream +protocol violation. ### 6.3 Script creatives -Secure default kept; consequence made loud (5.5); enablement path documented. +`allow_script_creatives` stays default-`false` (defensible sandbox posture); +the consequence becomes loud (§5.8) and the enablement path documented. ### 6.4 Render identity -As G2. - -### 6.5 Fallback rendering - -As G4e — attribution-gated, not ownership-gated; timeouts never render; the -renderer is converted to an awaitable API with cancellation and terminal -reasons before the fallback lands. - -### 6.6 Renderer endpoint — unconditional, versioned, observable - -- The static renderer document route registers unconditionally in every - adapter (the APS provider stays config-gated). Startup validation fails - loudly if an auth handler pattern covers it. -- **Caching matches immutability:** `/integrations/aps/renderer/v1` is an - immutable artifact — its bytes change only by shipping `/v2` — so it is - served with `Cache-Control: immutable` (long max-age), published to shared - artifact storage in deploy stage 1 like every versioned asset (G5), which - also answers version-skew (`/v2` requests reaching older instances are - served from storage). Revision 3's `no-store` contradicted the versioning - and is corrected. -- Two-stage acknowledgement (G4b): authenticated `document_loaded`, then the - runner-load result — splitting `renderer_document_no_load` from - `runner_no_load`/`runner_failed`. -- **Server route counters are aggregate** (requests, unknown-version, - auth-blocked): the document request carries no trace (the nonce travels in - the URL fragment, which never reaches the server), so no row-level join is - claimed. -- **CSP report-only canary, fully specified:** reports go to a dedicated - `POST /_ts/csp-reports` route (same pre-parse caps and same-origin rules as - 5.3; credentials ignored; rate-limited fail-closed); stored as **aggregate - counters only** (directive, blocked-origin **host only** — full URLs - redacted) on sink-backed adapters, count-and-drop elsewhere; enforcement - follows only after a clean canary window. - -### 6.7 One descriptor schema - -As revision 3 (review-accepted): tagged-envelope schema generated from a -separate wire-schema crate/xtask; semantic validators hand-written on both -sides; outer-tolerance only, exact AAX projection; shared -positive + adversarial corpus across Rust, TS, and the inline document; -staleness CI. +As G2, including the `(trace_id, nav_gen, refresh_gen)` registry scope and +capacity rules. + +### 6.5 Fallback + +As G4e/G4a; awaitable renderer first; attribution-gated; timeouts never +render. + +### 6.6 Renderer endpoint + +- The static renderer document route registers **unconditionally in every + adapter** (the APS provider stays config-gated); startup validation fails + if an auth handler pattern covers it; §G5 route-isolation rules apply + (early dispatch, all methods reserved, no publisher fall-through). +- Path `/integrations/aps/renderer/v1`, embedded in the binary, served + `Cache-Control: immutable` (its bytes change only by shipping `/v2` in a + new release; §0's purge retires the old). Unknown versions → `404` + `no-store`. +- **Two-stage acknowledgement:** authenticated `document_loaded` (proves + route + auth + document CSP), then the runner-load result — splitting + `renderer_document_no_load` from `runner_no_load`/`runner_failed`. +- Server route counters (requests, unknown-version, auth-blocked) are + **aggregate only** — the document request carries no trace (the nonce + rides the URL fragment and never reaches the server). +- **CSP rollout that cannot false-pass:** discovery uses the **currently + enforced** policy with reporting attached (report-only alone cannot reveal + what the enforced policy already blocks). Candidate relaxations are tested + in a small enforced cohort under a short-lived canary document version; + once frozen, a new immutable `/v2` ships with the final policy. Reports: + dedicated `POST /_ts/csp-reports` accepting **both** + `application/csp-report` (legacy) and `application/reports+json` + (Reporting API), each with its own payload validator; §5.4 caps and + rate-limit rules; **origin rules account for the renderer's opaque + sandbox** (reports may carry `null` origin — validated by document URL / + policy version instead of the beacon's same-origin rule); stored as + aggregate counters with blocked sources bucketed into + `https-host | data | blob | inline | eval | other` (no arbitrary host + labels — cardinality abuse is otherwise trivial). Browser coverage for + opaque-renderer reports runs on **Chromium, Firefox, and WebKit** (CI is + Chromium-only today, `playwright.config.ts:16`; the matrix extends for + this suite). + +### 6.7 One descriptor schema — generation covers all three implementations + +- Wire truth: the tagged `BidRenderer` envelope (discriminator on the enum, + `types.rs:188-211`). +- A wire-schema crate/xtask (separate from `trusted-server-js`; core already + depends on that crate, `Cargo.toml:45`) generates: the JSON-Schema + artifact, the **TS structural parser**, the **ES5-compatible inline + validator fragment** embedded in the renderer document, and shared + fixtures — all checked in with staleness CI. Only environment-specific + semantic checks (URL/origin policy, canonical base64, length bounds, the + exact one-bid AAX projection, cross-field equality) stay handwritten. +- Tolerance only on the outer versioned descriptor; the decoded AAX envelope + remains an exact projection. A shared positive + adversarial corpus runs + through the Rust validator, the generated TS parser, and the generated + inline fragment in CI. ### 6.8 Bridge hardening -As revision 3 (review-accepted): source-first ownership with the bounded -parent-chain SafeFrame walk (depth 5, known slot-root `WindowProxy` map, no -tree scans); adversarial test set; top-of-listener hygiene; dead branch -deleted; renderer branches emit trace records under G4's taxonomy, with G4d -notifications only where the bid path carries them (never APS). - ---- - -## 7. Workstream 3 — TSJS target architecture +Processing order (normative — preserves the existing stolen-capability +defense that suppresses propagation before source validation, +`gpt/index.ts:1547`): + +1. parse `e.data` (bare `catch` → return); +2. identify a TS-reserved ad id (registry lookup); +3. if TS-reserved: `stopImmediatePropagation()` **before** any validation — + a rejected foreign frame must not be answerable by Prebid's native + handler either; +4. validate source ownership via the bounded walk: known slot-root + `WindowProxy` map, sender's own parent chain (`event.source.parent`, …) + to depth 5 — never scanning an attacker-controllable frame tree; +5. validate nonce, token, `nav_gen`, `refresh_gen` (G4b); +6. respond, or refuse with `bridge_id_mismatch`. + +Non-TS ad ids are untouched (no propagation suppression). The stolen-token +browser test asserts **neither TS nor the native Prebid listener responds**; +listener-registration ordering has a real-browser assertion. The dead +duplicate renderer branch (C5) is deleted; renderer branches emit the full +§5.1 sequence with G4d notifications only on carrying paths. + +## 7. TSJS target architecture ### 7.1 Layering -Kernel / adapters / services / integrations exactly as revision 3, with the -boundary lint in CI. Stateful services via the G3 ABI only. +``` +kernel/ boot, config, queue, event bus, log, beacon, sessions +adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access +services/ slots (registry+handoff), auction client, render engine, consent +integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services) +``` + +Boundary lint in CI (`import/no-restricted-paths`): kernel imports nothing +above it; adapters import kernel only; services import kernel + adapters; +integrations import kernel + services, never each other. This dissolves the +audited inversions (`core/auction.ts` and `core/request.ts` importing +`integrations/aps/render`; `gpt` and `prebid` importing `aps`; `prebid` +owning the GPT refresh wrapper). Stateful services via the G3 registry only. ### 7.2 Adapters -`present | pending | timed_out` with non-terminal `timed_out` (late loaders -transition to `present`); per-operation timeouts with disposition reasons. +Per external global: `present | pending | timed_out`, `timed_out` +non-terminal (late loaders transition to `present` and drain what is still +valid); queued operations carry their own timeouts and expire with +disposition reasons. ### 7.3 Slot registry service -Kernel-owned registry (`WeakMap` + div-id index) -holding ownership, adoption, handoff claims, responsive resolution, pending -request cycles (G4a), and targeting-key history. No expandos on GPT objects. - -### 7.4 Global namespace policy — with a compatibility window - -As revision 3 (review-accepted): `window.tsjs` + `tsjs._internal`; the public -queue keeps its real name **`tsjs.que`**; public globals -(`tscreative`, `tsCreativeConfig`, `tsjs.que`) get dual-read/write for two -release cycles / ≥ 60 days, closing only on the adoption gate (old-name usage -< 0.1% of traces for 14 days, measured on sink-backed deployments); -`requestAds` keeps its void signature; `requestAdsAsync` is the new versioned -API; private globals migrate immediately. +Kernel-owned; `WeakMap` + div-id index; holds +ownership (ts/publisher/adopted), handoff claims, responsive resolution, +G4a intent queue + cycle state (RuntimeSession-scoped), targeting-key +history. No expandos on GPT objects (`__tsRenderGeneration`/`__tsRenderBid` +deleted). + +### 7.4 Final global surface (hard cutover — no dual names) + +| Legacy surface (removed at cutover) | Final shape | +| -------------------------------------------- | ----------------------------------------------------------------------------- | +| `window.tsjs.que` | `window.tsjs.que` — unchanged, the one public queue | +| `globalThis.tscreative` (API) | `tsjs.creative.*` (same methods, namespaced) | +| `globalThis.tsCreativeConfig` (pre-load) | `tsjs.boot.creative` inside the boot container (below) | +| `requestAds` (void) — and rev-4's dual API | **one** async contract: `tsjs.requestAds(options): Promise` | +| `window.__tsjs_*` flags, integration configs | `tsjs.boot.*` fields written by server-injected scripts before the bundle | +| server install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) | +| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` | +| `tsjs._internal` | kernel-owned registry (G3), **frozen after boot** | + +Boot container lifecycle: pre-core scripts write +`window.tsjs = window.tsjs || {que: [], boot: {}}` fields; the kernel +**consumes `boot` at boot, deep-freezes the retained copy, and deletes +consumed one-shot secrets** (the trace authorization moves into the sealed +NavigationSession). Old pages referencing removed names fail at cutover — +accepted per §0. ### 7.5 Messaging module -All `postMessage` through one module: versioned envelopes, name constants, -G4b nonces, 6.8 source validation. A **minimal** messaging module (envelope + -constants + validation helpers used by the bridge) lands early (Phase 1) so -Phase 3 does not depend on Phase-4 structure; the full migration of every -legacy call site completes in Phase 4. - -### 7.6 Plugin lifecycle and session model - -As revision 3 (review-accepted), with G3's sharpened version semantics: -manifest versions are ranges (major + minMinor); quarantine on -incompatibility; first-wins only among compatible. Sessions: -`RuntimeSession` / `NavigationSession` / `RenderAttempt` with enumerable -disposal inventories. Error policy: no empty `catch`; auction fetch gets -timeout + `AbortController`. **Console logging retained, not replaced** -(paired `warn` with the beacon's reason code; `debug`-level -delivery/security failures promoted to `warn`). - -### 7.7 The bootstrap problem - -As revision 3: queue-and-flags stub + bundle replay behind its own flag with -replay-timing specs; the no-bundle fallback generated from the TypeScript -source. - -### 7.8 GPT correctness fixes carried with the restructure - -- **Unconditional early GPT event subscription:** the `slotRenderEnded` - (and `slotRequested`) listeners register on the command queue at install, - no longer gated behind `!ts.servicesEnabled` (`gpt/index.ts:1017`) — G4a - cannot work on bootstrap-first pages otherwise. Recording is idempotent so - double-registration cannot double-count. -- Restore #922/#997 attribution and orphan recovery. -- `changeCorrelator: false` on TS-initiated refreshes (configurable). -- `enableSingleRequest()` only when GPT services are not already enabled. -- Ambiguous responsive resolution emits `render_fail{slot_unresolved}`. +All `postMessage` through one module: versioned envelopes, name constants +(the `'Prebid Request'` literal exists at six sites today; the APS handshake +in three copies), G4b nonces, §6.8 validation. The minimal module (envelope + +constants + validators used by the bridge) lands in Phase 1; full call-site +migration completes in Phase 4. + +### 7.6 Plugin lifecycle — transactional — and sessions + +`tsjs.definePlugin(id, install, dispose?)` with `install(ctx)`: + +- `ctx.signal` (aborted on quarantine/disposal); synchronous + `ctx.onDispose(fn)` registration; effects must be registered as they are + made. +- **Unwind on failure:** a throw, rejection, or abort triggers automatic + reverse-order invocation of the disposers registered so far — partial + installs cannot leak effects. Per-disposer exception isolation (one + throwing disposer cannot stop the rest). +- A disposer registered (or returned) **after** the owning session was + disposed is invoked immediately. +- Pending late registrations (manifest requested, bundle not yet evaluated): + capacity 16, bound 10 s, then `bundle_partial`. +- Release matching per G3: a plugin whose `release_id` differs from the + kernel's is quarantined before `install` runs. +- Sessions: `RuntimeSession` (page lifetime: bridge listener, history hook, + pbjs subscriptions, adapters, beacon queue, **slot cycle state**); + `NavigationSession` (per navigation: trace + authorization, render + attempts, slot aliases, targeting history); `RenderAttempt` (per G4a cycle + or G4f attempt). Each owns an enumerable disposal inventory; navigation + disposes only NavigationSession children. +- Error policy: no empty `catch` — handle, log with context, or emit a + disposition. The auction fetch gains timeout + `AbortController`. +- **Console logging retained:** every issue-surfacing condition keeps or + gains a `log.warn` carrying the same reason code as its beacon event; + `debug`-level delivery/security failures are promoted to `warn`. + +### 7.7 Bootstrap + +`gpt_bootstrap.js` (495 ES5 lines duplicating handoff/initial-load/hydration +logic, with the live `servicesEnabled` divergence) shrinks to a +queue-and-flags stub; the bundle replays recorded early calls on install. +Replay changes observable ordering — it ships inside the cutover with +browser specs covering replay timing. The no-bundle fallback ("ads render if +the bundle fails", pinned by `gpt.rs:1174-1179`) is **generated from the +same TypeScript source** at build time. + +### 7.8 GPT correctness carried with the restructure + +Unconditional early `slotRequested`/`slotRenderEnded` subscription (replacing +the `!servicesEnabled` gate, `gpt/index.ts:1017`; recording idempotent); +restore #922/#997 attribution and orphan recovery; `changeCorrelator: false` +on TS refreshes (configurable); `enableSingleRequest()` only when GPT +services are not already enabled; ambiguous responsive resolution emits +`render_fail{slot_unresolved}` alongside its warning. ### 7.9 Decomposition targets -As revision 3 (gpt/prebid splits, script-guard consolidation, trace model/UI -split, `global.d.ts` fix). - -### 7.10 Performance - -Budgets tightened per review: per-bundle raw/gzip/Brotli for the exact -ordered module vector vs a checked-in baseline, **+5% byte tolerance**; -browser timing assertion (bids-script-to-first-`display()`) on a **pinned CI -runner class and pinned browser version**, **5 warm-up runs discarded, 50 -measured samples, gate on p90 with +10% latency tolerance**; server bench -(precomputed concatenation) gates CPU and heap at **±10% vs baseline** with -pinned tool versions. Precompute lands in Phase 0 with asset identity (G5). - -### 7.11 Toolchain and dependency currency - -As revision 3 (review-accepted): raise the TypeScript floor to the resolved -5.9 line, then evaluate the next major separately; strictness flags on; dev -toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from casual -bumps; monthly review policy. - ---- +| Today | Target | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | +| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics | +| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | +| `core/trace.ts` (model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | +| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal | + +### 7.10 Performance (reproducible) + +- Bundle budgets: raw/gzip/Brotli per bundle for **three module vectors** + (minimal, reference, maximal), compressors pinned (`gzip -9`, + `brotli -q 11`), compared to checked-in baseline artifacts + (`perf/baselines/*.json`); baseline updates are explicit reviewed diffs. + Tolerance +5% bytes. +- Browser timing (bids-script-to-first-`display()`): pinned CI runner class + (the repository's standard Linux runner image) and the + Playwright-pinned browser build; 5 warm-up runs discarded, 50 samples, + gate p90 ≤ baseline × 1.10. +- Server (precomputed concatenation): one-sided gates, CPU and heap ≤ + baseline × 1.10; improvements always pass. Tool versions pinned in + `.tool-versions`. + +### 7.11 Toolchain + +Raise the TypeScript floor to the resolved 5.9 line (lockfile already +resolves 5.9.3 under the stale `^5.5.4` manifest); adopt +`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, +`verbatimModuleSyntax`; dev-toolchain bumps (eslint, prettier, jsdom, +`@playwright/test`, `@types/node`) as individual CI-gated PRs with changelog +review (this library monkeypatches `fetch`/`sendBeacon`/DOM prototypes — +jsdom/Playwright changes are real risks); `prebid.js` excluded from casual +bumps (runtime Prebid is the manifest-locked external bundle; the npm pin +and deployed bundle version documented together); monthly review; no phase +starts more than one minor behind stable. ## 8. Migration plan -Each phase ships behind a feature flag with **phase-specific gates** (the -render-fail canary cannot evaluate phases that predate the metric): - -- **Phase 0 — Asset identity, contracts, toolchain.** Two-stage artifact - publishing (shared immutable storage) + path-based identity + ordered-vector - precompute + rolling-deploy tests in both directions + legacy-URL test; - toolchain floors; contracts G1–G5 as code-adjacent docs; delete dead expando - writes; server drop-reason surfacing. - _Gate:_ zero unexpected `410`s and zero legacy-URL breakage on canary; asset - hit/miss counters nominal. -- **Phase 1 — Kernel ABI, sessions, minimal messaging, minimal cycle - registry.** Versioned registry with `(major, minor)` semantics; - `RuntimeSession`/`NavigationSession`; install manifest; the minimal - messaging module (7.5) and the cycle-aware slot-record core (G4a's queue) - land here so Phase 3 has its dependencies; unconditional early GPT - subscriptions (7.8). - _Gate:_ ABI install-success counters clean; zero `abi_mismatch` on canary; - no listener regression in browser specs. -- **Phase 2 — Trace and beacon.** Server-minted initial trace + page-bids - `X-TSJS-Trace-Id` echo + capability issuance (G1, 5.3); beacon service; - four-adapter ingest; `ts_client_events`. - _Gate:_ ingest acceptance/drop/abuse counters nominal; trace join rate on - sink-backed canary ≥ 95% of sampled traces. -- **Phase 3 — APS delivery.** Wire-schema crate + corpus; mediation helper + - opt-in merge; render token; unconditional versioned renderer route + - two-stage ack; bridge hardening; request-cycle protocol + render state - machine + awaitable renderer + scoped notifications (G4a–G4d); the opt-in - fallback (G4e); restore #922/#997; correlator and SRA fixes. - _Gate:_ `render_fail` rate within +0.5% absolute of pre-flag baseline over - 24 h on canary; fill/latency/billing volume deltas within agreed bounds +Phases are internal build milestones of **one coordinated release** (§0): +each lands behind a flag in the dark deployment; the cutover switches them +on together. Gates are executable — every gate names query, cohort, +denominator, minimum sample, threshold, window, owner (release owner unless +stated), and action (hold cutover / rollback switch): + +- **Phase 0 — Identity, schemas, toolchain.** Path-hashed embedded assets + + 410 semantics + ordered-vector precompute; `format_version`; §5.6 schemas + deployed and validated (writer-off); toolchain floors; dead expando writes + deleted; §5.8 server drop surfacing. + _Gate:_ dark-instance health 100%; datasource validation green (rejection + rate < 0.1% on synthetic writes, freshness ≤ 5 min); asset `410` rate on + dark probes = 0 for known hashes. +- **Phase 1 — Kernel, sessions, minimal messaging, cycle registry.** G3 + registry (exact release ids); RuntimeSession/NavigationSession; install + manifest; minimal messaging module; G4a intent/cycle records; unconditional + GPT subscriptions. + _Gate:_ browser-spec suite green incl. listener-order assertions; zero + `abi_mismatch`/`bundle_partial` on dark probes. +- **Phase 2 — Trace + beacon.** Server-minted initial trace + page-bids + header echo + signed authorizations; beacon service; four-adapter ingest; + `ts_client_events` writers on. + _Gate:_ on dark probes: ingest acceptance ≥ 99%, group auth-rejection + < 0.5%, dedup query returns exactly-once per `(trace, seq)`; freshness + ≤ 5 min over a 24 h window. +- **Phase 3 — APS delivery.** Schema crate + corpus (6.7); mediation + algorithm + required `winner_selection` (6.1); render token (G2); + renderer route + two-stage ack + CSP report route (6.6); bridge order + (6.8); G4a–G4f state machines, awaitable renderer, scoped notifications, + fallback; #922/#997 restoration; correlator + SRA fixes. + _Gate (canary cohort vs simultaneous control cohort, 24 h, minimum 10,000 + sampled attempts each):_ APS `render_accepted` / attributable APS attempts + ≥ 95%; renderer-document load rate ≥ 99%; runner failure+timeout ≤ 1%; GAM + fill, p90 latency, and billing volume deltas within ±2% of control (billing measured against GAM/server-side reporting, not the beacon); - release-gating real-GAM overlap test green. -- **Phase 4 — Structure.** Full layering + boundary lint; plugin lifecycle - completion; adapters; full slot registry; full messaging migration; - namespace window (7.4). - _Gate:_ boundary lint zero exceptions; disposal-inventory leak tests green. -- **Phase 5 — Decomposition.** File splits; script-guard consolidation; - bootstrap shrink (own flag, replay-timing specs); compatibility-window - close (adoption-gated). - _Gate:_ bundle budgets and timing assertions hold; adoption gate met before - any removal. - ---- + real-GAM overlap test green. Action on breach: hold cutover. +- **Phase 4 — Structure.** Full layering + boundary lint; transactional + plugin lifecycle; adapters; full slot registry; full messaging migration; + final namespace (7.4). + _Gate:_ boundary lint zero exceptions; disposal-inventory leak tests + green; pre-cutover page smoke on the final namespace. +- **Phase 5 — Decomposition + cutover.** File splits; script-guard + consolidation; bootstrap stub + generated fallback; then the §0 runbook. + _Gate:_ bundle budgets + timing assertions hold; cutover checklist signed + off; post-switch monitor window 24 h on the §5.7 objectives; rollback = + traffic switch back. ## 9. Test acceptance matrix -Blocking CI is hermetic; the staged smoke suite (real GAM line items, -including the G4a overlap test) is release-gating. - -| Area | Must cover | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Mediation | both lifecycles; ties, floors, currency rejection; provenance dedup; transformed ids → `mediator_provenance_unresolved`; `mediator_only` default; rollback blob round-trip | -| Cache identity | non-APS cache-backed bids byte-identical; PUC `?uuid=` path | -| Render token | format/CSPRNG/in-auction retry/TTL/one-time/per-`(trace, nav_gen)` scoping | -| Request cycles | ts-vs-publisher classification; one-outstanding-TS-cycle serialization; publisher refresh overlapping TS cycle → `cycle_unattributable` fail-closed; late events; **real-GAM overlap (release gate)** | -| Ack protocol | nonce validation (source, token, nav_gen, refresh_gen); SSAT + client-Prebid + nested SafeFrame; stale/replayed acks | -| Render semantics | notifications only on carrying paths (never APS); `nurl` at bind, `burl` at accepted, attempt-scoped idempotency; `billed_then_failed`; no paint claims (`adm_document_loaded` labeling); accepted-but-blank | -| Fallback | renders only on attributed `gam_empty` (adopted **and** TS-owned); publisher-initiated cycles never trigger; timeout diagnostics-only; SPA cancellation; destruction; exactly-once terminal | -| Bridge security | wrong-slot/stolen/replayed/prior-navigation tokens; nested foreign frames; bounded parent-chain walk; SafeFrame positive | -| Beacon | initial-trace join; page-bids header echo + response trace/capability; per-trace grouping across navigation-spanning batches; `seq`-gap partial traces; ingest abuse incl. absent Origin; capability signature; sendBeacon Blob | -| Schema | staleness; adversarial corpus ×3 validators; outer-tolerance vs exact AAX projection | -| Runtime ABI | one kernel under concatenation; deferred late registration; failure isolation; **mixed-version verdicts (compatible-range install vs quarantine)**; first-wins among compatible only | -| Lifecycle | `timed_out → present`; session disposal inventories; stale async install; pre-init `tsCreativeConfig` + `tsjs.que`; dual-name window | -| Delivery | two-stage deploy simulation **both rolling directions**; new-HTML-hash on old instance (storage lookup); retained-hash miss; legacy query-hash sunset path; unknown hash 410 `no-store`; immutable exact-match only; ordered vector | -| Renderer endpoint | route in all adapters; auth-pattern startup failure; `/v1` immutable caching; version-skew via storage; `document_loaded` vs runner split; CSP report route caps/redaction | -| Adapter parity | ingest, CSP-report, renderer routes and drop-reason surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | -| Policy | script-creative warning; `invalid_dimensions` + bounded width/height; page-bids `debug` gating; diagnostic unsampled completeness; trace-bearing HTML `private, no-store` invariant | - ---- +Hermetic CI (deterministic PUC/message harness) blocks PRs; the staged +real-GAM suite is release-gating. Rows added this revision are marked •. + +| Area | Must cover | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Request cycles | intent-vs-request separation; • initial-load-disabled cycle formation (display creates no request); SRA batching; `intent_no_request`; publisher overlap → quarantine; • old-navigation completion before and after replacement; drain/re-arm; real-GAM overlap (gate) | +| Ack protocol | five-field validation; SSAT/client-Prebid/SafeFrame; stale + replayed acks; • acks after navigation disposal | +| Bridge security | • TS-reserved id rejected with propagation stopped — neither TS nor native Prebid responds; wrong-slot/stolen/prior-navigation tokens; bounded parent-chain walk; listener-order real-browser assertion; SafeFrame positive | +| Render semantics | notifications only on carrying paths (never APS); bind per flow (PUC claim / direct render-start / fallback pre-render); `burl` at `render_accepted`; attempt-scoped idempotency; `billed_then_failed`; accepted-but-blank | +| Direct `/auction` | • full G4f lifecycle: attempt keys, refresh_gen increments, cancellation on navigation, exactly-once terminal, same event sequence | +| Fallback | only attributed `gam_empty` (adopted + TS-owned); publisher-initiated never; timeout never renders; SPA cancellation; • flag change during an active attempt | +| Mediation | • candidate-id echo; • transformed/unresolvable provenance → slot fails closed for merging; • duplicate candidates dedup; • deterministic ties independent of arrival order; currency validation at the Prebid parse point; both lifecycles; required `winner_selection` | +| Render token | format/CSPRNG/in-auction retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scoping; • registry capacity → `registry_full` | +| Trace auth | • HMAC verification: expiry, skew, max-future, missing kid, rotation (previous key), constant-time path; per-group rejection; cache-privacy invariant (`private, no-store`) | +| Beacon | initial + SPA trace joins; per-trace grouping; seq gaps; • duplicate fetch/pagehide delivery deduped at sink; queue overflow marker; ingest abuse; sendBeacon Blob type | +| Ingest/limits | • token-bucket rate + burst; • limiter saturation and address churn; • map capacity/TTL/eviction; fail-closed drop with 204 | +| Internal routes | • wrong-method → 405 + Allow + no-store on every adapter; • unknown version → 404 no-store; • no publisher fall-through; • dispatch before auth/EC/filters; • no body/cookie/authorization forwarding | +| CSP | • both media types with separate validators; • opaque/null-origin renderer reports accepted; • bucketed aggregation only; • Chromium/Firefox/WebKit capture; • enforced-policy discovery vs canary-cohort relaxation | +| Schema | staleness; adversarial corpus through Rust + generated TS + generated inline fragment; outer tolerance vs exact AAX projection | +| Runtime ABI | one kernel under concatenation; exact-release verdicts (match runs, mismatch quarantines); late registration; failure isolation | +| Plugins | • partial synchronous install unwound in reverse order; • async rejection; • abort while pending; • disposer-after-disposal invoked immediately; per-disposer isolation | +| Lifecycle | `timed_out → present`; session disposal inventories; boot container consume/freeze/delete; final-namespace smoke (`tsjs.que`, `tsjs.creative`, async `requestAds`) | +| Delivery | unknown hash 410 no-store; immutable on exact match; ordered-vector precompute; cutover runbook rehearsal (switch + purge + rollback switch) | +| Sink | • datasource-side freshness + rejection monitoring (sink is fire-and-forget); • sink auth/schema rejection surfaced by monitor | +| Failure injection | • Amazon runner redirect, network hang, CSP block, script error → distinct §5.1 outcomes | +| Adapter parity | ingest, CSP-report, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | +| Policy | script-creative warning; `invalid_dimensions` + bounded w/h; `dimensions_out_of_range` unclamped; page-bids `debug` gating; diagnostic completeness | ## 10. Alternatives considered -Unchanged from revision 3 (patching without telemetry; always-direct-render; -single module graph; big-bang rewrite; dropping the bootstrap; -timeout-triggered fallback — all rejected for the recorded reasons). +1. **Patching APS point-failures without telemetry** — rejected: three + correct fixes produced no ads; the next fix would be another guess. +2. **Always direct-render APS (skip GAM/PUC)** — rejected: unilaterally + changes GAM reporting/pacing; kept only as the attributed-`gam_empty` + fallback. +3. **Single module graph / shared chunks now** — rejected for this release: + changes the delivery pipeline while everything else changes; recorded as + the successor behind the same registry surface. +4. **Full rewrite in one branch without phases** — rejected: the browser-spec + safety net is thinnest exactly where behavior changes. +5. **Dropping the ES5 bootstrap** — rejected: loses the pinned no-bundle + guarantee; generation from TS keeps it without dual maintenance. +6. **Timeout-triggered fallback** — rejected: GPT requests cannot be + cancelled; a timeout race can double-render and double-bill. +7. **N/N−1 compatibility machinery** (revisions 3–4) — removed by the §0 + policy decision: version ranges, retained artifacts, legacy URLs, and + dual-name globals deleted in favor of exact release matching and a + coordinated switch. ## 11. Risks -Revision 3's list, plus: **shared-storage dependency for assets** (stage-1 -publish becomes a deploy prerequisite; mitigated by the embedded fast path -for the current vector and deploy-time verification that storage matches the -embedded hashes); **notification-trigger semantics** are now a published -contract for PBS-path demand — changing them later is a breaking change for -SSP reporting expectations. +- **Hard cutover blast radius:** in-flight pages fail at switch; accepted by + policy (§0); bounded by the purge + 24 h monitored window + traffic-switch + rollback. +- **Mediator wire-contract change** (candidate-id echo) requires + coordinating the mediator implementation; until echoed ids exist, + `merge_highest_cpm` cannot be enabled (config validation enforces this). +- **Notification triggers become a published contract** for PBS-path demand; + changing them later is a breaking change for SSP reporting. +- **Beacon abuse:** bounded by pre-parse caps, origin checks, fail-closed + numeric rate limits, server-decided sampling, signed authorizations. +- **Registry/limiter memory:** all client and server maps carry explicit + capacities, TTLs, and eviction rules (G2, §5.4). +- **CSP relaxation:** enforced-cohort canary + new immutable version prevent + false-clean canaries; bucketed aggregation prevents cardinality abuse. +- **Sink blindness:** fire-and-forget dispatch is compensated by mandatory + datasource-side freshness/rejection monitoring. ## 12. Success criteria -1. APS creatives render on a reference page in each configured flow, - hermetically in CI and via the staged smoke suite (including the real-GAM - overlap test). -2. Every failure point in section 2 maps to a distinct observable signal; - diagnostic mode names the failing reason from one page load; production - telemetry meets the 5.4 SLO on sink-backed deployments. -3. Boundary lint zero exceptions; stateful sharing only via the versioned - ABI; mixed-version delivery resolves to the G3 verdicts. -4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or +1. APS creatives render in each configured flow — SSAT, client-Prebid, + page-bids, and direct `/auction` (G4f) — hermetically in CI and in the + release-gating real-GAM suite. +2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the + failing class from one page load; §5.7 objectives hold on sink-backed + deployments. +3. Boundary lint zero exceptions; stateful sharing only via the G3 registry; + exact-release mismatches quarantine loudly. +4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or generated. 5. Trace counts are per-impression; orphan recovery has a non-vacuous test; - cycle attribution follows G4a including the publisher-overlap fail-closed - rule. -6. The only TSJS-owned global is `window.tsjs` (public globals only inside - their window, closed by the 7.4 adoption gate); no expandos on GPT slots, - GPT functions, or `pbjs`. -7. Bundle budgets (+5% bytes) and the pinned-environment p90 timing assertion - (50 samples, +10% tolerance) hold; server concatenation is precomputed - within ±10% CPU/heap of baseline. -8. No existing warning is lost; every issue-surfacing condition logs at - `warn` or above with the beacon's reason code. + cycle attribution follows G4a (physical requests, publisher-overlap + quarantine, drain/re-arm). +6. The only TSJS-owned global is `window.tsjs` with the §7.4 final shape; no + expandos on GPT slots, GPT functions, or `pbjs`; legacy names are gone at + cutover. +7. §7.10 budgets hold (three vectors, pinned tools, one-sided server gates). +8. No existing warning is lost; every issue-surfacing condition logs `warn`+ + with the beacon's reason code. 9. TypeScript floor matches the resolved 5.9 line with strictness flags on; - `prebid.js` pin matches the documented deployed bundle; monthly review - policy in CI docs. -10. Rolling-deploy tests pass in both directions; legacy URLs serve through - their sunset; unknown hashes 410 `no-store`; immutable only on exact - match. -11. `nurl`/`burl` fire only on carrying paths, on their G4d transitions, + `prebid.js` pin matches the documented deployed bundle. +10. `nurl`/`burl` fire only on carrying paths at their G4d binds, attempt-scoped and idempotent; APS fires neither. -12. Trace-bearing responses are `private, no-store` by test; capabilities are - per-trace and never authorize a trace they were not bound to. +11. Trace-bearing responses are `private, no-store` by test; authorizations + are per-trace, signed, mode-carrying, and never accepted unsigned. +12. The cutover runbook has been rehearsed (switch, purge, rollback switch) + before the production switch. ## 13. Open questions @@ -701,8 +927,7 @@ SSP reporting expectations. should the original be re-merged? 5. Do Axum/Cloudflare/Spin get real client-event sinks, or keep accept-count-drop? -6. Does Amazon expose any creative-completion acknowledgement that could - reintroduce a confirmed state (G4c) under a new name? -7. Which shared storage backs stage-1 artifact publishing per platform (KV - store vs config store vs CDN), and who owns the publish step in the deploy - pipeline? +6. Does Amazon expose any creative-completion acknowledgement that could add + a confirmed state beyond `render_accepted` under a new name? +7. Who implements and owns the mediator-side candidate-id echo (6.1), and on + what timeline relative to this release? From 5cca7525a8accaa36688feedd0e2ef0b798176f3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:21:06 -0700 Subject: [PATCH 006/194] Close residual review deltas on revision 5 The fifth review round matched revision 4's text and line positions; revision 5 already contained the contested contracts (slotRequested- opened cycles, RuntimeSession drain state, propagation-stopping bridge order, per-flow nurl bind, G4f direct lifecycle, inlined mediation algorithm, signed server-decided sampling, observation events in the wire enum, ts_client_events schema, dedup keys, numeric limiter bounds, isolated internal routes, opaque-origin CSP handling, transactional plugins, final namespace table, refresh_gen token scope, generated inline validator, control-cohort Phase 3 gates, and the failure-to-signal mapping). This commit patches the genuine remainders: minimum key strength and missing-key startup behavior for the trace authorization, client-events sink batch cap with startup validation, sink-unavailable behavior, canonical join and alert ownership, the three-instrument CSP rollout (enforced discovery, report-only tightening, enforced-cohort relaxation with named gates and kill switch) with unused-field discard, p95 for the Phase 3 latency gate, a four-flow behavioral-parity gate on Phase 4, named runner image and browser pinning with baseline-validity rules, the operator-query note on the failure mapping, and the wire-event clarification for G4c observations. --- ...s-render-fix-and-tsjs-resilience-design.md | 78 ++++++++++++------- 1 file changed, 52 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index 4cf8c4d4d..b02247a72 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -107,7 +107,9 @@ time; a bid that never painted is byte-identical to one that painted. ### 2.5 Failure → signal mapping (normative) Every section-2 failure maps to a distinct observable **failure class** (not -a claim to distinguish unknowable root causes): +a claim to distinguish unknowable root causes). The operator query for each +row is the §5.6 canonical join filtered by that row's event/reason or +counter; the console column is what diagnostic mode mirrors on-page: | Failure | Client event/reason (§5.1) | Server counter/row (§5.6) | Console | | ------- | -------------------------------------------- | -------------------------------------- | ------------ | @@ -368,6 +370,11 @@ configured flow" in the success criteria includes this one. ] } ``` +Every G4c observation is a **wire event** in this enum (internal state +transitions map to them one-to-one; nothing observable is state-only). +`gam_empty` additionally appears as a `render_fail` reason when it is the +terminal outcome of an attributed attempt. + The `t` enum now **contains every G4c observation**, so Phase-3 stage rates (renderer-document load rate, runner load/failure, GAM fill) are queryable. Reason enum (closed): `renderer_document_no_load`, `runner_no_load`, @@ -392,7 +399,10 @@ fetch/pagehide races are expected and handled at the sink (§5.5). Format `v1....`: - `kid`: key id; **active and previous keys** live in the platform secret - store; rotation = introduce new key as active, demote, retire. + store; keys are ≥ 256-bit CSPRNG values; rotation = introduce new key as + active, demote, retire. **Missing-key startup behavior:** if the beacon is + enabled and no signing key resolves at startup, startup fails loudly + (config error) — traces are never issued unsigned. - `exp`: unix epoch seconds; verifier allows ±60 s skew; maximum future 15 minutes from issuance. - `mode`: `sampled` | `diagnostic`. Sampling is server-decided (G1); @@ -443,7 +453,13 @@ id_kind (Enum), matched (UInt8), source (Enum), reason (Enum §5.1)`. Sorting key `(publisher, ts, trace_id, seq)`; 30-day TTL; its own ingest token, configured via new `TinybirdSettings` fields (`client_events_dataset`, `client_events_token_secret`) — today's settings - configure only the auction dataset (`settings.rs:1752`). + configure only the auction dataset (`settings.rs:1752`). Sink batch cap: + 512 rows per dispatch (matching the auction sink). Startup validation: + when client events are enabled, the dataset name and token secret must + resolve or startup fails. Sink-unavailable behavior at runtime: + accept-count-drop (ingest still answers `204`). Canonical join: + `ts_client_events` ⋈ auction rows on `(publisher, trace_id)`; dashboards + and alerts are owned by the release owner and defined with the datasource. - **Auction rows** (`AuctionEventRow`, `telemetry.rs:262`, and `auction_events_raw.datasource`): add nullable `trace_id (FixedString 32)` and `mode`; add a bounded **`bid_drop` row type** @@ -564,23 +580,27 @@ render. - Server route counters (requests, unknown-version, auth-blocked) are **aggregate only** — the document request carries no trace (the nonce rides the URL fragment and never reaches the server). -- **CSP rollout that cannot false-pass:** discovery uses the **currently - enforced** policy with reporting attached (report-only alone cannot reveal - what the enforced policy already blocks). Candidate relaxations are tested - in a small enforced cohort under a short-lived canary document version; - once frozen, a new immutable `/v2` ships with the final policy. Reports: - dedicated `POST /_ts/csp-reports` accepting **both** - `application/csp-report` (legacy) and `application/reports+json` - (Reporting API), each with its own payload validator; §5.4 caps and - rate-limit rules; **origin rules account for the renderer's opaque - sandbox** (reports may carry `null` origin — validated by document URL / - policy version instead of the beacon's same-origin rule); stored as - aggregate counters with blocked sources bucketed into - `https-host | data | blob | inline | eval | other` (no arbitrary host - labels — cardinality abuse is otherwise trivial). Browser coverage for - opaque-renderer reports runs on **Chromium, Firefox, and WebKit** (CI is - Chromium-only today, `playwright.config.ts:16`; the matrix extends for - this suite). +- **CSP rollout that cannot false-pass.** Three distinct uses, each with its + own instrument: **discovery** uses the **currently enforced** policy with + reporting attached (report-only alone cannot reveal what the enforced + policy already blocks); **tightening** candidates run report-only; + **relaxation** candidates are tested in a small enforced cohort under a + short-lived canary document version, gated on runner acceptance rate, CSP + violation rate, render-failure rate, and a kill switch that reverts the + cohort to the frozen policy. Once frozen, a new immutable `/v2` ships with + the final enforced headers. Reports: dedicated `POST /_ts/csp-reports` + accepting **both** `application/csp-report` (legacy) and + `application/reports+json` (Reporting API), each with its own payload + validator; §5.4 caps and rate-limit rules; **origin rules account for the + renderer's opaque sandbox** (reports may carry `null` origin — validated + by document URL / policy version instead of the beacon's same-origin + rule); unused report fields are **discarded before logging or + aggregation**; stored as aggregate counters with blocked sources bucketed + into `https-host (allowlisted) | data | blob | inline | eval | other` (no + arbitrary host labels — cardinality abuse is otherwise trivial). Browser + coverage for opaque-renderer reports runs on **Chromium, Firefox, and + WebKit** (CI is Chromium-only today, `playwright.config.ts:16`; the matrix + extends for this suite). ### 6.7 One descriptor schema — generation covers all three implementations @@ -747,10 +767,13 @@ services are not already enabled; ambiguous responsive resolution emits `brotli -q 11`), compared to checked-in baseline artifacts (`perf/baselines/*.json`); baseline updates are explicit reviewed diffs. Tolerance +5% bytes. -- Browser timing (bids-script-to-first-`display()`): pinned CI runner class - (the repository's standard Linux runner image) and the - Playwright-pinned browser build; 5 warm-up runs discarded, 50 samples, - gate p90 ≤ baseline × 1.10. +- Browser timing (bids-script-to-first-`display()`): named runner image + `ubuntu-24.04` (the CI image already used by this repository's workflows) + and the Chromium build bundled with the pinned `@playwright/test` version + from `package.json`; 5 warm-up runs discarded, 50 samples, gate p90 ≤ + baseline × 1.10. The baseline artifact records image, browser, and tool + versions alongside the numbers; a baseline update is invalid if any of + those differ from the pinned set. - Server (precomputed concatenation): one-sided gates, CPU and heap ≤ baseline × 1.10; improvements always pass. Tool versions pinned in `.tool-versions`. @@ -803,14 +826,17 @@ stated), and action (hold cutover / rollback switch): _Gate (canary cohort vs simultaneous control cohort, 24 h, minimum 10,000 sampled attempts each):_ APS `render_accepted` / attributable APS attempts ≥ 95%; renderer-document load rate ≥ 99%; runner failure+timeout ≤ 1%; GAM - fill, p90 latency, and billing volume deltas within ±2% of control + fill, p95 latency, and billing volume deltas within ±2% of control (billing measured against GAM/server-side reporting, not the beacon); real-GAM overlap test green. Action on breach: hold cutover. - **Phase 4 — Structure.** Full layering + boundary lint; transactional plugin lifecycle; adapters; full slot registry; full messaging migration; final namespace (7.4). _Gate:_ boundary lint zero exceptions; disposal-inventory leak tests - green; pre-cutover page smoke on the final namespace. + green; pre-cutover page smoke on the final namespace; **behavioral-parity + suite green across all four flows** (SSAT, client-Prebid, page-bids, + direct `/auction`) comparing pre- and post-restructure event sequences on + the reference page. - **Phase 5 — Decomposition + cutover.** File splits; script-guard consolidation; bootstrap stub + generated fallback; then the §0 runbook. _Gate:_ bundle budgets + timing assertions hold; cutover checklist signed From 41667537e730f5edea59a620142304b4e243cb0f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:02:53 -0700 Subject: [PATCH 007/194] Revise design spec: adopt the 248fe9558 baseline and answer round five Advances the baseline to the APS PUC/collapsed-shell fix (citations refreshed; C5 marked fixed; G4b rebuilt on the MessageChannel transport with a three-message authenticated sequence; gam_collapsed added as an observation with the guarded resize adopted as a sanctioned exception) and resolves the fifth review round: a 256-byte auth bound with full token encoding rules, a signed unsampled mode that transmits nothing, an operator-credential diagnostic gate replacing the non-security tester cookie, trace issuance on the direct /auction path via header and response extension, causal intent classification that retires known zero-request display intents and quarantines ambiguous overlap, removal of timeout re-arm in favor of drain/destroy/page-end, RuntimeSession tombstones preserving bridge suppression across navigations, an arrival- independent mediation order on intrinsic candidate keys with required auction currency and strategy-specific timeouts, a per-event field matrix with nullable storage columns and dedicated overflow and billing-outcome events, an adapter rate-limiter abstraction with per-platform semantics and reject-at-capacity, direct-auction serialization and notification plumbing, idempotent field-wise boot initialization with fallback activation and arbitration, auth renewal for long-lived pages, selection_summary rows, canonical dedup views with heartbeat monitoring, a complete settings schema, boot.debug envelopes making every failure class one-page-load visible, an object-form plugin API carrying release ids, construction-time concatenation caching, forgery-resistant CSP report routing with frozen per-version header manifests, a single router-weight rollout state machine with phase decision records and a checked-in gates table, survivorship-safe Phase 3 metrics, and a fully pinned performance workflow. --- ...s-render-fix-and-tsjs-resilience-design.md | 1533 +++++++++-------- 1 file changed, 784 insertions(+), 749 deletions(-) diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index b02247a72..0251485b9 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1,57 +1,49 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 5 — rewritten for the coordinated hard-cutover policy - adopted in the fourth review round, and made fully self-contained (no - contract is defined by reference to an earlier revision). +- **Status:** revision 6 — baseline advanced to the APS PUC/collapsed-shell + fix, and reworked after the fifth review round. - **Date:** 2026-08-04 -- **Baseline:** `rc/july` @ `541298695` — the full merged state. -- **Inputs:** three code audits against this baseline; design reviews of - revisions 1–4; open issues #926, #941, #944, #962, #964, #977, #983, #989, - #993; open PR #997. +- **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed + GAM shells") — the full merged state. All file:line citations refer to this + commit. +- **Inputs:** three code audits; design reviews of revisions 1–5; open issues + #926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997. ## 0. Release policy: coordinated hard cutover -This design targets a **single coordinated release**. Explicitly: - -- Server, TSJS bundles, config format, and page HTML ship together as one - release with one **release id** (`release_id`: the git tag / build hash). -- **No N/N−1 support.** Old pages, old bundles, old config blobs, old - globals, and old URLs may stop working at cutover. In-flight clients (pages - loaded before the switch) may fail; this is accepted and stated, not - mitigated. -- **Exact release matching only.** The kernel, every service, every plugin, - and the install manifest carry the same `release_id`; any mismatch is a - refusal, never a negotiation. There are no version ranges. -- **Config format version:** the config blob gains a top-level - `format_version`; the binary rejects any other value. No - omit-default-for-rollback serialization rules; rollback means redeploying - the previous release with its own config. -- **Assets:** binaries embed only their own release's artifacts. Hashed - pathnames are kept **for cache identity only** (correct caching and - dedup), not for retention: an unknown hash answers `410 Gone`, - `Cache-Control: no-store`. No shared artifact store — no separate - requirement for one was established. -- **Cutover runbook (normative):** (1) deploy the release to all instances - dark (flagged off); (2) verify instance health and config - `format_version`; (3) switch traffic atomically at the routing/CDN layer; - (4) purge the CDN of prior HTML and assets; (5) monitor the Phase gates - (§8); rollback = switch traffic back to the previous release and re-purge. - -Everything that revisions 3–4 built for mixed-version tolerance is removed: -no ABI version ranges, no retained-artifact storage, no legacy query-hash -sunset, no dual-name global window, no adoption gates. +This design targets a **single coordinated release**: + +- Server, TSJS bundles, config format, and page HTML ship together under one + **`release_id`** (git tag / build hash). **No N/N−1 support**: old pages, + bundles, config blobs, globals, and URLs may stop working at cutover; + in-flight clients may fail. Accepted and stated, not mitigated. +- **Exact release matching only** — kernel, services, plugins, and the + install manifest carry the same `release_id`; mismatch is a refusal. +- **Config:** top-level `format_version`, exact match required. Rollback = + redeploy the previous release with its own config. +- **Assets:** binaries embed only their release's artifacts; hashed pathnames + exist for cache identity only; unknown hash → `410 Gone`, `no-store`. +- **One executable rollout state machine** (resolving the §0/§8 tension the + review found): a release ships with a **deployment manifest** enumerating + the complete flag set; the new pool comes up **fully enabled but + unreachable except by probes**; phase gates (§8) run against probe traffic + and a **router-weight canary of coherent routed requests** (a request is + served end-to-end by one pool — HTML, assets, and APIs never mix pools); + **router weight is the sole activation primitive**; cutover = weight to + 100% + CDN purge; rollback = weight back + re-purge. Flags exist for + emergency kill switches inside a pool, not as the activation mechanism. ## 1. Problem statement APS demand is fully integrated server-side — the edge runs the APS OpenRTB auction, wins bids, and ships a typed renderer descriptor to the page — yet -APS creatives do not appear for real users. Serial single-cause fixes (the -`bid.meta` carrier, the decoupled prebid shim, the `hb_adid` fallback) each -survived review and still did not produce ads. That pattern is the finding: -the APS pipeline has **multiple independent failure points, most of which -fail silently**, and the client cannot tell the server which one fired. +APS creatives do not appear reliably for real users. Four serial fixes (the +`bid.meta` carrier, the decoupled prebid shim, the `hb_adid` fallback, and +now the baseline's PUC/collapsed-shell fix) each survived review; the pattern +is the finding: the pipeline has **multiple independent failure points, most +of which fail silently**, and the client cannot tell the server which fired. -The TSJS library (56 files, ~11,900 lines, two ~1,700-line monoliths, +The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths, duplicated ES5/TS logic, inverted layering, ~100 error-swallowing `catch` blocks) is the same problem structurally. This design fixes APS delivery and rebuilds TSJS so the next integration cannot reproduce this failure class. @@ -61,8 +53,7 @@ rebuilds TSJS so the next integration cannot reproduce this failure class. - No change to the APS OpenRTB endpoint contract or Amazon-side configuration (including its deliberate absence of `nurl`/`burl`, §G4d). - No rewrite of the decoupled Prebid.js strategy. -- **No backward compatibility** (§0). Publisher-visible surfaces change at - cutover; the replacement shapes are in §7.4. +- **No backward compatibility** (§0); replacement surfaces are in §7.4. ## 2. Why APS does not render — evidence @@ -76,28 +67,28 @@ descriptor without GAM. | # | Failure | Where | | --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | A1 | A configured `[auction].mediator` discards every direct-provider bid; winners come only from the mediator response. APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` | -| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; the drop is counted but invisible (A4). | `aps.rs:141-143`, `:773-778` | -| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:657-668`, `:745-778`, `:838-846` | +| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; the drop is counted but invisible (A4). | `aps.rs:161`, `:334`, `:793` | +| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:675`, `:763-796`, `:859` | | A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and the `ts-debug` allowlist exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | ### 2.2 Identity | # | Failure | Where | | --- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| B1 | GAM caps key-value values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge equality check with no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1613` | +| B1 | GAM caps key-value values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge equality check with no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1695` | | B2 | Two id universes: SSAT keys on the APS bid id, the client adapter on Prebid's generated `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` | ### 2.3 Render -| # | Failure | Where | -| --- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` is reachable only from flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` | -| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1188-1245`, `aps/render.ts:384-404` | -| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:157-183`, `:1599-1600` | -| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` | -| C5 | A dead duplicate renderer branch holds the debug log and dedup logic; it can never execute. | `gpt/index.ts:1643-1674` | -| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` | -| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1558-1632` | +| # | Failure | Where | +| --- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` is reachable only from flow (d). | `gpt/index.ts:923-1180`, `core/request.ts:59` | +| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1247`, `aps/render.ts:30`, `:415-437` | +| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` | +| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` | +| C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated; the served-through-APS-renderer log is reachable. | `gpt/index.ts:1729` | +| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` | +| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` | ### 2.4 Observability @@ -106,458 +97,538 @@ time; a bid that never painted is byte-identical to one that painted. ### 2.5 Failure → signal mapping (normative) -Every section-2 failure maps to a distinct observable **failure class** (not -a claim to distinguish unknowable root causes). The operator query for each -row is the §5.6 canonical join filtered by that row's event/reason or -counter; the console column is what diagnostic mode mirrors on-page: - -| Failure | Client event/reason (§5.1) | Server counter/row (§5.6) | Console | -| ------- | -------------------------------------------- | -------------------------------------- | ------------ | -| A1 | — | selection report `mediator_superseded` | startup warn | -| A2 | — | `bid_drop{script_rendering_disabled}` | startup warn | -| A3 | — | `bid_drop{invalid_dimensions, w, h}` | warn | -| A4 | — (fixed by §5.6 itself) | `bid_drop` rows exist on all paths | `ts-debug` | -| B1/B2 | `bridge_request{matched: false}` | join via `trace_id` | warn | -| C1 | `gam_empty` then no `bridge_request` | join via `trace_id` | warn | -| C2 | `render_fail{renderer_document_no_load}` | renderer route counters | warn | -| C3 | `render_fail{bridge_id_mismatch}` | join via `trace_id` | warn | -| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | warn | -| C5 | — (branch deleted) | — | — | -| C6 | `runner_failed` + CSP report buckets | CSP aggregate counters | warn | -| C7 | renderer branch emits the full §5.1 sequence | join via `trace_id` | debug/warn | - -## 3. The GPT reality this design must respect +Each failure maps to a distinct observable **failure class** (not a claim to +distinguish unknowable root causes). The operator query for each row is the +§5.6 canonical view filtered by that row's event/reason or counter. So that +**diagnostic mode really can name every class from one page load** — the +review's objection that A1–A4 are server-side-only — the tester gate also +delivers a **`tsjs.boot.debug` envelope** in the initial HTML (selection +summary + drop summary for the initial auction) and the equivalent gated +`ext.trusted_server.debug` field on page-bids and `/auction` responses; +diagnostic mode mirrors these to the console: + +| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface | +| ------- | -------------------------------------------- | ------------------------------------- | ------------------------------- | +| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary | +| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary | +| A3 | — | `bid_drop{invalid_dimensions, w, h}` | `boot.debug` drop summary | +| A4 | — (fixed by §5.6 itself) | `bid_drop` rows on all paths | `boot.debug` / response `debug` | +| B1/B2 | `bridge_request{matched: false}` | join via trace | console warn | +| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn | +| C2 | `render_fail{renderer_document_no_load}` | renderer route counters | console warn | +| C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn | +| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn | +| C5 | — (fixed at baseline) | — | — | +| C6 | `runner_failed` + CSP buckets | CSP aggregate counters | console warn | +| C7 | renderer branch emits the full §5.1 sequence | join via trace | debug/warn | + +## 3. The GPT and baseline reality this design must respect 1. Bootstrap-first hybrid: server-injected ES5 `gpt_bootstrap.js` wins the sentinel race; the bundle's handoff/initial-load code is dead in production. 2. The #922 merge loss: orphan recovery and `updateRender` are gone - (`0dc9b19a9`); `__tsRenderGeneration`/`__tsRenderBid` are dead writes; - bridge impressions double-count. PR #997 is the apparent replacement. + (`0dc9b19a9`); bridge impressions double-count. PR #997 is the apparent + replacement. 3. TS refreshes never pass `changeCorrelator: false`. -4. `enableSingleRequest()` is called blind after publisher `enableServices()`. -5. Responsive resolution ambiguity silently skips slots. -6. Three independent `pubads().refresh` wrappers coordinate via window-global - booleans. +4. `enableSingleRequest()` is called blind after publisher + `enableServices()`. +5. Responsive-resolution ambiguity silently skips slots. +6. Three independent `pubads().refresh` wrappers coordinate via + window-global booleans. 7. **GPT has no request cancellation, no documented per-refresh identity, no overlapping-completion order.** `slotRenderEnded` means creative code was - injected, not that resources loaded. The April 2025 `responseIdentifier` - identifies the ad **response** — usable for response dedup/drain, not for - attributing which caller initiated a request. + injected, not that resources loaded. `responseIdentifier` identifies the + ad **response** — usable for response dedup/drain, never initiation + attribution. 8. **With initial load disabled, `display()` creates no request** — the - subsequent `refresh()` does (`gpt/index.ts:1059`, `ad_init.test.ts:1201`). - Any cycle protocol must model physical requests, not API calls. + subsequent `refresh()` does (`gpt/index.ts:1175`, + `ad_init.test.ts:1201-1263`). Cycle protocols must model physical + requests. 9. The bundle's `slotRenderEnded` registration is gated behind - `!ts.servicesEnabled` (`gpt/index.ts:1017`); G4a needs unconditional early - subscription. + `!ts.servicesEnabled` (`gpt/index.ts:1091`); G4a needs unconditional + early subscription. +10. **The baseline includes the fourth serial APS fix** (`248fe9558`): (a) + the renderer handshake moved to a **MessageChannel** on the PUC path — + port-transferred nonce message, descriptor over the port (no wildcard + broadcast on that path), exact-key replies, `ports.length` checks, a + one-shot `accepted` latch, port closing (`aps.rs:65-125`, + `aps/render.ts:415-437`) — but the reply still terminates inside the PUC + creative frame, so G4b's kernel-observability gap remains; (b) a + nonempty GAM render can be a **collapsed 1×1 shell**, remediated by + `resizeCollapsedCreativeFrame` (`gpt/index.ts:217`) — a guarded style + mutation of GAM-owned elements; (c) the dead renderer branch was + consolidated (C5); (d) a real-PUC-topology browser test now exists. +11. The bridge already keeps **consumed-id tombstones** for security + (`gpt/index.ts:1527`) — G2's registry rules must preserve that property + across navigations. +12. The current tester cookie is **explicitly not a security control** + (`tester_cookie.rs:3`) — it cannot gate unsampled telemetry (§5.3). ## 4. Design gates ### G1 — Trace identity and correlation -The client-visible auction id is EC-derived (`publisher.rs:3237`) and is -never ingested. Initial-HTML auction telemetry is emitted before page JS -exists (`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by +The client-visible auction id is EC-derived (`publisher.rs:3237`) and never +ingested. Initial-HTML auction telemetry is emitted before page JS exists +(`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by whoever acts first: - **Initial navigation (`nav_gen 0`):** the server mints `trace_id` (128-bit - CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction rows - (§5.6 schema), and injects it into `tsjs.boot` with a **signed trace - authorization** (§5.3). It is never `AuctionRequest.id`. -- **Cache privacy invariant:** traces/authorizations are injected only into + CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction rows, + and injects it into `tsjs.boot` with the signed authorization (§5.3). +- **Cache-privacy invariant:** traces/authorizations are injected only into responses that ran a per-request auction; such HTML is - `Cache-Control: private, no-store` with no validators. Enforced by - construction and by test. + `Cache-Control: private, no-store`, no validators. By construction + test. - **SPA navigations:** `/_ts/page-bids` stays GET; the client mints - `trace_id` and sends it in a validated `X-TSJS-Trace-Id` header (the - request already carries a non-simple TSJS header). The server records it in - that auction's rows; the JSON response echoes the accepted trace and - returns its signed authorization. + `trace_id`, sends it in `X-TSJS-Trace-Id`; the server records it and the + response echoes the trace + its authorization. +- **Direct `/auction` (closing the G4f gap):** the client sends the same + `X-TSJS-Trace-Id` header on the POST (`core/auction.ts:190` gains it); the + server validates, stamps that auction's rows, and echoes + `ext.trusted_server.trace = {trace_id, auth}` in the OpenRTB response — + covering pages whose initial HTML ran no auction. - **Envelope:** every event carries `{nav_gen, refresh_gen, seq}` inside a - per-trace group `{trace_id, auth, events[]}` (§5.1). `seq` is per-trace - monotonic. Transport is best-effort: gaps (loss) and duplicates - (fetch/pagehide races) are both expected; §5.5 defines dedup. -- **Sampling is server-decided for every trace** — initial via boot, SPA via - the page-bids response — and carried **inside the signed authorization** - (§5.3 `mode`). The client never asserts its own sampling; an unsigned or - missing authorization means the trace group is rejected at ingest. + per-trace group `{trace_id, auth, events[]}`. `seq` is per-trace + monotonic. Gaps (loss) and duplicates (fetch/pagehide races) are expected; + §5.5 dedups. +- **Sampling is server-decided for every trace** and carried inside the + signed authorization (§5.3). Traces are navigation-scoped; **impression / + attempt counts are keyed by `(trace_id, nav_gen, refresh_gen, slot)`** + (success criterion 5 uses this key, not "per-impression traces"). ### G2 — Render identity -- Cache-backed bids: `hb_adid` = the PBS Cache UUID, byte-for-byte as today - (`publisher.rs:3355`; the PUC fetches `?uuid=`, - `publisher.rs:3450`, `gpt/index.ts:1700`). Markup bids without cache ids: - today's fallback chain unchanged. -- **Renderer-only bids: `hb_adid` = a server-minted render token**, - `^[a-z0-9]{12}$` exactly, CSPRNG, collision-retried within the minting - auction. Cross-auction uniqueness is probabilistic (36¹² ≈ 4.7×10¹⁸; - birthday-bound negligible at realistic volumes) and made harmless by - registry scoping. -- **Registry scope and bounds:** the client bridge registry keys tokens by - `(trace_id, nav_gen, refresh_gen)` — refresh auctions within one - navigation cannot collide, closing the revision-4 gap. Capacity: 64 live - entries per navigation; at capacity a new registration is refused with - disposition `registry_full`; unexpired entries are evicted only by - navigation disposal. Token TTL 15 minutes; one-time consumption. -- The client-Prebid path keeps Prebid's generated `adId`; both paths register - into the one registry keyed by whichever id that path observes. -- Regression tests: non-APS cache-backed bids byte-identical. - -### G3 — Runtime ABI under the IIFE build (exact-release model) +- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte + (`publisher.rs:3355`; PUC fetches `?uuid=`, `gpt/index.ts:1772`). Markup + bids: today's fallback chain. +- **Renderer-only bids:** `hb_adid` = server-minted token `^[a-z0-9]{12}$`, + CSPRNG, collision-retried within the minting auction; cross-auction + uniqueness probabilistic (36¹²; negligible) and harmless via scoping. +- **Registry:** keyed `(trace_id, nav_gen, refresh_gen)`; capacity 64 live + entries per navigation (`registry_full` on refusal); TTL 15 min; one-time + consumption. **Navigation disposal does not erase security state** + (review's tombstone finding): consumed, stale, and disposed ids move into + a bounded **RuntimeSession tombstone set** (cap 256, FIFO, entries retained + until their original TTL) — the bridge's TS-reserved check consults live + registry **and** tombstones, so a late prior-navigation request is still + suppressed and refused, never released to native Prebid. This preserves + the baseline's existing consumed-id tombstone behavior + (`gpt/index.ts:1527`). +- The client-Prebid path keeps Prebid's `adId`; both paths share the one + registry. Non-APS cache-path regression tests. + +### G3 — Runtime ABI under the IIFE build (exact-release) IIFE-per-bundle with inlined imports (`build-all.mjs:46`, `bundle.rs:23`) means imports never share state across bundles (live defect: `core/context.ts:11` vs `permutive/index.ts:102`). -- The kernel ships only in `tsjs-core`, publishes - `tsjs._internal = { release_id, registry }` once (window sentinel), and - **freezes** `_internal` after boot. The kernel constructs and registers - core services (event bus, beacon queue, sessions, slot registry, render - state machine) during boot; integrations register integration-scoped - services during `install()`. -- **Exact release matching:** every service and plugin registration carries - `release_id`; `registry.get(name)` succeeds only when the registrant's - `release_id` equals the kernel's. A mismatch quarantines the registration - and emits `abi_mismatch` (service) / `bundle_partial` (plugin) with a - console error. No ranges, no minors, no first-wins tiers — under §0 a - mismatch is a deployment error to surface, not tolerate. -- Stateful access only through the registry at call time; stateless helpers - may be imported and inlined. Single-module-graph builds remain the - recorded successor option behind the same surface. +- Kernel ships only in `tsjs-core`; publishes + `tsjs._internal = { release_id, registry }` once; freezes after boot; + constructs and registers core services during boot. +- **Exact release matching:** every registration carries `release_id` + (plugins via the object-form API, §7.6, whose `release` field is a + build-generated constant); `registry.get(name)` succeeds only on equality; + mismatch quarantines with `abi_mismatch` / `bundle_partial` and a console + error. +- Stateful access only via the registry at call time; stateless helpers may + inline. Boundary enforcement is **two lint rules**: `import/no-restricted- +paths` for layering **and** `no-restricted-globals` forbidding + `window.googletag` / `window.pbjs` outside `adapters/` (import paths alone + cannot enforce §7.1). ### G4 — Render lifecycle -**G4a — Physical request-cycle protocol.** Two separated notions: - -- **Intent:** a TS `display()`/`refresh()` call (or an observed publisher - entry) targeting a slot. Intents are classified `ts | publisher` at the - wrapped entry points. An intent may produce zero physical requests - (initial-load-disabled `display()`; `refresh()` on a never-displayed - adopted slot); an intent that produces no `slotRequested` within its bound - (2 s) expires with disposition `intent_no_request` — diagnostics only. -- **Cycle (outstanding physical request):** opened **only by - `slotRequested`**, matched to the oldest unexpired TS intent for that slot, - else classified publisher-initiated. SRA batching yields one `slotRequested` - per slot per batch — one cycle each, all matched to the intents of the - batch call. A cycle closes on its `slotRenderEnded` (matched by slot; GPT's - `responseIdentifier`, where present, deduplicates responses during drain — - it never attributes initiation). -- **Serialization:** TS keeps at most one outstanding TS cycle per slot. A TS - intent arriving while a TS cycle is outstanding **queues** (bounded: 1 - queued replacement; further intents coalesce into it). -- **Attribution:** a `slotRenderEnded` is attributable iff exactly one - TS cycle is outstanding for the slot and no publisher-initiated or - untracked request overlaps it. Any overlap → the slot enters - **quarantine**: `cycle_unattributable`, fail closed (no fallback, no state - transition), and the drain rule applies. -- **Drain/re-arm (supersession and SPA):** physical cycle state lives in the - **RuntimeSession-owned slot record**, not the NavigationSession — adopted - slots outlive navigations. On navigation or supersession, outstanding - cycles are marked stale; their late events are **matched and discarded** - with disposition `stale_navigation` (never misattributed); a quarantined or - stale slot re-arms only after every outstanding request/render pair has - drained (or its 60 s drain bound elapses, which keeps the slot - fallback-ineligible for that navigation). Queued TS intents dispatch only - after re-arm. A timeout never makes an old event disappear — drain-by-match - does. -- CI exercises the protocol on the deterministic harness; a **release-gating - real-GAM overlap test** (publisher refresh racing a TS cycle; - initial-load-disabled cycle formation) validates it against actual GPT. - -**G4b — Acknowledgement protocol.** The renderer document currently posts -"ready" only to its immediate parent (`aps.rs:105`); in the PUC path the -top-level kernel cannot observe it, and callbacks fire on send -(`gpt/index.ts:1572`, `:1620`). Contract: the bridge response embeds a -**per-attempt 128-bit CSPRNG acknowledgement nonce**; the renderer document -posts versioned `{t: "render_accepted" | "render_failed", nonce, reason?}` -to the top window; the kernel validates, in order: source ownership (§6.8 -walk), nonce equality, token binding, `nav_gen`, `refresh_gen` — all five — -before any state transition or notification. Pinned by tests for SSAT, -client-Prebid, and nested SafeFrame flows, including stale and replayed -acks. +**G4a — Physical request-cycle protocol.** + +- **Intents, both classes, one causal queue.** Every observable initiation — + TS `display()`/`refresh()` and wrapped publisher entries — records an + intent in causal order, classified `ts | publisher`. A TS `display()` + issued while initial load is disabled is **known at call time to produce + no request** and is retired immediately as bookkeeping (it never enters + the matcher) — closing the review's misattribution case where a publisher + `refresh()` inside the 2 s window would have been consumed by a stale TS + intent. TS intents that _may_ produce no request only in hindsight + (`refresh()` on a never-displayed adopted slot) expire at 2 s with + `intent_no_request`; **if any publisher intent is recorded for the slot + while such a TS intent is pending, the next `slotRequested` is ambiguous + and the slot quarantines** — a zero-request TS intent can never silently + win FIFO matching. (Exact test in §9.) +- **Cycles:** opened only by `slotRequested`, matched to the head of the + causal intent queue; SRA batching yields one `slotRequested` per slot per + batch. A cycle closes on its `slotRenderEnded`; `responseIdentifier` + deduplicates responses during drain. +- **Serialization:** at most one outstanding TS cycle per slot; one queued + TS replacement (later intents coalesce). +- **Attribution:** a `slotRenderEnded` is attributable iff exactly one TS + cycle is outstanding and no publisher/untracked request overlaps. + Overlap → quarantine (`cycle_unattributable`, fail closed). +- **Drain/re-arm (no timeout re-arm).** Physical cycle and drain state live + in the RuntimeSession slot record; **unissued intents are + NavigationSession children** and are cancelled by navigation disposal. A + quarantined or stale slot re-arms only on: count-based drain (every + outstanding request/render pair matched), safe TS-owned slot destruction + and redefinition, or page end. **A timeout emits a diagnostic and never + restores attribution** — the 60 s bound from revision 5 is removed + because an old `slotRenderEnded` arriving after re-arm would be + indistinguishable from a new cycle. Late stale events are matched and + discarded (`stale_navigation`). +- CI exercises the protocol on the deterministic harness; a release-gating + **real-GAM overlap test** (publisher refresh racing a TS cycle; + initial-load-disabled formation) validates it against actual GPT. + +**G4b — Acknowledgement protocol (on the baseline port transport).** Since +`248fe9558` the frame pair speaks MessageChannel (parent-postMessage with +`ports.length === 0`, or transferred port with `ports.length === 1`; +exact-key replies; one-shot `accepted` latch; port closed after reply — +`aps.rs:65-125`, `aps/render.ts:415-437`). Adopted as the contract of record +within the frame pair. The kernel-observability gap remains (the PUC-flow +reply resolves inside the creative frame; callbacks fire on send, +`gpt/index.ts:1632-1760`). Contract — **three authenticated messages per +attempt**, each carrying the per-attempt 128-bit CSPRNG nonce from the +bridge response: + +1. `renderer_document_loaded` — posted to the top window after the document + validates the descriptor and nonce (this is §6.6's first stage, which + revision 5's two-message protocol omitted); +2. the port reply to its frame-pair peer (baseline behavior, unchanged); +3. `render_accepted` / `render_failed{reason}` — posted to the top window. + +The kernel validates, in order: source ownership (§6.8 walk), nonce, token, +`nav_gen`, `refresh_gen` — before any state transition or notification. The +one-shot latch + port close mean a re-render is a fresh document instance +with a fresh nonce. Pinned for SSAT, client-Prebid, and nested SafeFrame, +including stale/replayed acks and acks after navigation disposal. **G4c — Honest observations.** Inline-adm frames are sandboxed `srcdoc` -without `allow-same-origin` (`gpt/index.ts:358`) — opaque origins; geometry -proves nothing. Observations: `gam_nonempty`, `gam_empty`, -`renderer_document_loaded`, `runner_loaded`, `runner_failed`, -`adm_document_loaded`. Every path terminates at `render_accepted` -(authenticated per G4b where the renderer protocol exists; -`adm_document_loaded` stands in for adm frames). **No observation claims -paint**; there is no `render_confirmed`. A future trusted completion ack -(OQ6) may add a new state under a new name. - -**G4d — Win/billing notifications.** APS intentionally carries neither -`nurl` nor `burl` (`aps.rs:812`; the minimized AAX envelope excludes them; -the integration guide documents no generic APS beacons). APS billing lives in -the Amazon runner lifecycle; unchanged. - -For carrying paths (PBS and other OpenRTB providers), **bind is defined per -flow and is never selection or targeting** (targeting-only firing is -explicitly prevented today, `ad_init.test.ts:1824`): - -- PUC/GAM flow: bind = an owned, slot-and-ad-id-matched bridge claim. -- Direct `/auction` flow: bind = validated render start (slot resolved, - descriptor/markup validated, attempt created). -- Fallback flow: bind = attributed `gam_empty`, immediately before the - fallback render starts. - -`nurl` fires at bind; `burl` at `render_accepted`; both attempt-scoped -(idempotency key `(trace_id, nav_gen, slot, refresh_gen, hb_adid)`), fired at -most once, via `sendBeacon`/`no-cors fetch`, no retries. Terminal failure -after acceptance → `billed_then_failed` label; no un-firing. +without `allow-same-origin` (`gpt/index.ts:510`) — opaque; geometry proves +nothing (the shell dimensions are assigned by our own code). Observations: +`gam_nonempty`, `gam_empty`, **`gam_collapsed`** (nonempty render whose +shell computes ≤ 1px — the baseline's discovery), `renderer_document_loaded`, +`runner_loaded`, `runner_failed`, `adm_document_loaded`. Every path +terminates at `render_accepted`; **no observation claims paint**; there is +no `render_confirmed`. The baseline's `resizeCollapsedCreativeFrame` +(`gpt/index.ts:217`) is adopted as a **sanctioned, guarded exception** to +the no-foreign-DOM-mutation rule (authenticated source frame only; wrapper +only when both dimensions ≤ 1px; anchor-ad `ins[data-anchor-status]` and +fixed/sticky guards) and emits `gam_collapsed` when it acts. + +**G4d — Win/billing notifications.** APS carries neither `nurl` nor `burl` +by design (`aps.rs:839`; the AAX envelope excludes them; the integration +guide documents no generic APS beacons) — APS billing lives in the Amazon +runner lifecycle, unchanged, and **APS is excluded from everything below**. + +For carrying paths (PBS and other OpenRTB providers): bind is per flow and +never selection or targeting (`ad_init.test.ts:1824` pins that): + +- GAM/PUC: an owned, slot-and-ad-id-matched bridge claim. +- Direct `/auction`: validated render start. **This requires server and + client work the current code lacks**: `/auction` response conversion must + preserve `nurl`/`burl` with server-side macro expansion + (`formats.rs:423` omits them today) and the client parser must carry and + https-validate them (`core/auction.ts:43` drops them today). +- Fallback: attributed `gam_empty`, immediately before fallback render. + +`nurl` at bind; `burl` at `render_accepted`; attempt-scoped idempotency key +`(trace_id, nav_gen, slot, refresh_gen, hb_adid)`; `sendBeacon`/no-cors +fetch; no retries. Post-acceptance terminal failure emits the dedicated +**`billing_outcome{billed_then_failed}`** event (§5.1) — it is not a +`render_fail` reason. **G4e — Fallback trigger.** Opt-in -(`[auction].client_render_fallback = "renderer"`). Renders only after a -terminal `gam_empty` **unambiguously attributed to a TS cycle** (G4a) — -ownership does not gate it (adopted slots are the common path and are -eligible); publisher-initiated or unattributable cycles never trigger it; -timeouts never render. The direct renderer is converted to an awaitable API -with cancellation and terminal reasons before the fallback lands. +(`[auction].client_render_fallback = "renderer"`); renders only after a +terminal `gam_empty` unambiguously attributed to a TS cycle; ownership does +not gate it; publisher-initiated or unattributable cycles never trigger it; +timeouts never render. **G4f — Direct `/auction` lifecycle.** The non-GPT path -(`core/request.ts:52`) gets the same discipline: a `RenderAttempt` keyed -`(trace_id, nav_gen, refresh_gen, slot)` where `refresh_gen` increments per -`requestAds` invocation for the same slot within a navigation; exactly-once -terminal state; G4b acknowledgement validation; G4d direct-flow bind; -cancellation and disposal on navigation; the same §5.1 event sequence. "Every -configured flow" in the success criteria includes this one. - -### G5 — Deployment contracts (hard cutover) - -- **Config:** top-level `format_version`; exact match required; mismatch is a - startup error. No default-omission rollback rules. -- **Assets:** hash-in-pathname (`/static/tsjs//.js`) for cache - identity; binaries serve only embedded current-release artifacts; unknown - hash → `410 Gone`, `no-store`; `Cache-Control: immutable` on exact matches. - Concatenations precomputed per **ordered module-ID vector** at build time. - The cutover runbook (§0) owns HTML/asset consistency; the CDN purge step is - what retires old references. -- **Internal route isolation (all adapters):** the renderer, client-events, - and CSP-report route families (a) dispatch **before** auth, EC setup, and - publisher/integration filters (today the renderer can traverse EC setup and - pre-route filters, `app.rs:709` in the Fastly adapter); (b) reserve **all - methods and all version prefixes** locally — unsupported method → - deterministic `405` with `Allow` and `no-store`; unknown version → `404` - `no-store`; never the publisher fall-through some adapters use today - (`adapter-spin app.rs:804`); (c) never forward bodies, cookies, or - authorization headers to publisher origins; (d) compare origins as - normalized scheme + host + port, not host-only. -- **Ingest routing:** client-events in all four adapters; Fastly has the real - sink; others accept-count-drop by contract (OQ5). -- **Storage:** §5.6 schemas deploy and validate **before** any writer - enables. +(`core/request.ts:52`) gets: a `RenderAttempt` keyed +`(trace_id, nav_gen, refresh_gen, slot)` with `refresh_gen` incremented per +`requestAds` invocation touching the slot; **per-slot serialization with +latest-wins cancellation** — concurrent calls for the same slot cancel the +older attempt, and every DOM or beacon side effect re-checks its attempt +generation first, so a reversed-arrival response can never replace a newer +creative or start a second economic lifecycle (`request.ts:31` currently +races); G4b acknowledgement validation; G4d direct-flow binds; disposal on +navigation; exactly-once terminal state; the full §5.1 event sequence. +`tsjs.requestAds(options)` returns +`Promise` where +`RequestAdsResult = { traceId, slots: Array<{ slot, outcome: "rendered" | +"no_bid" | "failed" | "cancelled", reason? }> }`, settling when every slot +attempt reaches a terminal state. Reversed-response tests required. + +### G5 — Deployment contracts + +- Config `format_version` exact-match; rollback by redeploy. +- Assets: hash-in-pathname; embedded only; unknown hash 410 `no-store`; + `Cache-Control: public, max-age=31536000, immutable` on exact matches + (`immutable` alone carries no lifetime). **Concatenation is materialized + and cached at application-state construction from the validated + configured module vector** — not per request (`bundle.rs:23` today), and + not a build-time-only set, since enabled vectors are runtime + configuration; an unlisted vector is a startup error. +- Internal route families (renderer, client-events, CSP reports): dispatch + before auth/EC/publisher/integration filters (Fastly today runs EC setup + and pre-route filters first, `app.rs:709`); all methods and version + prefixes reserved locally (405 + `Allow` + `no-store`; unknown version + 404 `no-store`; never the publisher fall-through in `adapter-spin +app.rs:804`); no body/cookie/authorization forwarding; origins compared + as normalized scheme+host+port. +- Ingest routes exist in all four adapters; Fastly has the real sink; the + others accept-count-drop (OQ5 drives their gates). +- §5.6 schemas deploy and validate before writers enable. ## 5. Observability -### 5.1 Wire payload +### 5.1 Wire payload and per-event field matrix ``` { v: 1, traces: [ - { trace_id, auth, // auth: signed authorization (§5.3) - events: [ - { nav_gen, refresh_gen, seq, - t: "bid_received" | "targeting_set" | "bridge_request" | - "bridge_response_sent" | "render_attempt" | "render_accepted" | - "render_fail" | "gam_nonempty" | "gam_empty" | - "renderer_document_loaded" | "runner_loaded" | "runner_failed" | - "adm_document_loaded" | "fallback_start", - slot, // configured slot id if in the injected set, else "s" - id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none" - matched, // bridge_request only - source, // "renderer" | "adm" | "pbs-cache" | "gam" - reason } // render_fail only - ] } + { trace_id, auth, events: [ { nav_gen, refresh_gen, seq, t, ...fields } ] } ] } ``` -Every G4c observation is a **wire event** in this enum (internal state -transitions map to them one-to-one; nothing observable is state-only). -`gam_empty` additionally appears as a `render_fail` reason when it is the -terminal outcome of an attributed attempt. - -The `t` enum now **contains every G4c observation**, so Phase-3 stage rates -(renderer-document load rate, runner load/failure, GAM fill) are queryable. -Reason enum (closed): `renderer_document_no_load`, `runner_no_load`, -`runner_failed`, `descriptor_invalid`, `invalid_dimensions`, -`dimensions_out_of_range`, `bridge_id_mismatch`, `cycle_unattributable`, -`intent_no_request`, `stale_navigation`, `bridge_claim_timeout`, `gam_empty`, +Event types and their fields (closed enums; a field absent from a row is +absent from the wire and NULL in storage): + +| `t` | fields | +| -------------------------- | ------------------------------------ | +| `bid_received` | slot, id_kind, source | +| `targeting_set` | slot, id_kind | +| `bridge_request` | slot, id_kind, matched | +| `bridge_response_sent` | slot, source | +| `render_attempt` | slot, source | +| `render_accepted` | slot, source | +| `render_fail` | slot, source, reason | +| `gam_nonempty` | slot | +| `gam_empty` | slot | +| `gam_collapsed` | slot | +| `renderer_document_loaded` | slot | +| `runner_loaded` | slot | +| `runner_failed` | slot, reason | +| `adm_document_loaded` | slot | +| `fallback_start` | slot | +| `billing_outcome` | slot, outcome (`billed_then_failed`) | +| `client_queue_overflow` | dropped (count) | + +`slot` is a configured slot id or `s`; `id_kind` ∈ +`cache_uuid | render_token | prebid_adid | bid_id | none`; `source` ∈ +`renderer | adm | pbs-cache | gam`. Reason enum: +`renderer_document_no_load`, `runner_no_load`, `runner_failed`, +`descriptor_invalid`, `invalid_dimensions`, `dimensions_out_of_range`, +`bridge_id_mismatch`, `cycle_unattributable`, `intent_no_request`, +`stale_navigation`, `bridge_claim_timeout`, `gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, `pbjs_absent`, `bundle_partial`, `fallback_cancelled`, `abi_mismatch`, `registry_full`. +Queue overflow is its own event (`client_queue_overflow`), never a +`render_fail` — failure denominators stay clean. The payload carries **no +client timestamp**; the server stamps `received_at`, and ordering within a +trace is `seq`. ### 5.2 Transport `fetch(..., {keepalive: true, credentials: "omit"})` primary; `pagehide` fallback `navigator.sendBeacon(url, new Blob([json], {type: -"application/json"}))`. Flush every 5 s and on `visibilitychange`/`pagehide`. -**Client queue bound:** 256 events; overflow drops oldest, increments a -counter, and the final flushed batch carries one `render_fail{...}`-class -overflow marker event so truncation is visible. Duplicates from -fetch/pagehide races are expected and handled at the sink (§5.5). +"application/json"}))`. Flush every 5 s and on `visibilitychange`/ +`pagehide`. Client queue bound 256 events; overflow drops oldest and emits +`client_queue_overflow{dropped}`. ### 5.3 Signed trace authorization -Format `v1....`: - -- `kid`: key id; **active and previous keys** live in the platform secret - store; keys are ≥ 256-bit CSPRNG values; rotation = introduce new key as - active, demote, retire. **Missing-key startup behavior:** if the beacon is - enabled and no signing key resolves at startup, startup fails loudly - (config error) — traces are never issued unsigned. -- `exp`: unix epoch seconds; verifier allows ±60 s skew; maximum future - 15 minutes from issuance. -- `mode`: `sampled` | `diagnostic`. Sampling is server-decided (G1); - diagnostic is a distinct authenticated mode gated by the tester cookie at - issuance — not an overloaded "sampling off" bit. -- `sig`: HMAC-SHA-256 over the **domain-separated, length-prefixed** input - `"ts-trace-auth-v1" || len(origin) || origin || len(trace_id) || trace_id -|| len(mode) || mode || u64(exp)`, where `origin` is the externally visible - scheme+host+port. Constant-time comparison. -- Ingest verifies per trace group; a missing key id, expired, future-dated, - or invalid signature → that **group** is dropped-and-counted (other groups - in the batch survive). An unsigned `sampled` claim does not exist in the - wire format, so it cannot be asserted. +Format `v1....` — **`auth` has its own ingest bound of +256 bytes** (it cannot fit the general 64-char string cap; every other +string keeps 64): + +- `kid`: `^[a-z0-9-]{1,16}$`; active + previous keys in the platform secret + store; keys ≥ 256-bit CSPRNG; **previous keys are retained at least + 24 hours** (≫ max token lifetime + skew); missing key at startup with the + beacon enabled = startup failure. +- `exp`: canonical decimal unix seconds (no sign, no leading zeros); ±60 s + skew; max future 15 min. +- `mode`: `sampled | unsampled | diagnostic`. **`unsampled` is the signed + discard decision** (the review's missing state): the client must not + enqueue or transmit events for an `unsampled` trace, and ingest rejects + any group whose token mode is `unsampled`; the decision is sticky for the + trace (renewals preserve mode). `diagnostic` is a distinct authenticated + mode — **not** gated by the tester cookie, which is explicitly + non-security (`tester_cookie.rs:3`); it requires a separate short-lived + **diagnostic credential** issued behind the existing operator/admin + authentication (`/_ts/admin` surface): HMAC-signed, bound to publisher + origin, expiry ≤ 60 min, revoked by key rotation, issuance + CSRF-protected; forgery/replay tests required. The tester cookie may + still gate cosmetic overlays; never telemetry volume. +- `sig`: base64url, unpadded, of HMAC-SHA-256 (43 chars) over the + domain-separated input + `"ts-trace-auth-v1" || u32be(len(origin)) || origin || +u32be(len(trace_id)) || trace_id || u32be(len(mode)) || mode || +u64be(exp)`, all strings UTF-8; constant-time comparison. +- **Renewal for long-lived pages:** before expiry the client calls + same-origin `GET /_ts/trace-auth` with `X-TSJS-Trace-Id`; the server + re-signs the **same trace id and mode** with a fresh `exp` (correlation is + the unchanged trace id). On renewal failure the client stops transmitting + and counts locally — silent rejection at ingest is thereby a bug, not a + policy. +- Ingest verifies per trace group; invalid/expired/unknown-kid → group + dropped-and-counted; other groups survive. ### 5.4 Ingest contract -- `POST /_ts/client-events`; `Content-Type: application/json` only; no - `Content-Encoding`; responds `204`, `no-store`; never echoes input. -- Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars; - `trace_id ^[0-9a-f]{32}$`; integers in `[0, 2³¹)`. +- `POST /_ts/client-events`; `application/json` only; no + `Content-Encoding`; `204`, `no-store`; never echoes input. +- Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars except + `auth` ≤ 256 bytes; `trace_id ^[0-9a-f]{32}$`; integers `[0, 2³¹)`. - Same-origin: `Sec-Fetch-Site: same-origin` when present, else normalized `Origin` equality; absent both → drop-and-count. -- **Rate limiting (numeric, fail-closed for telemetry):** token bucket per - client address, **10 requests/min, burst 20**; limiter map ≤ 65,536 - entries, entry TTL 10 min, LRU eviction; limiter unavailable/errored → - drop early with `204` (count only). Trusted client address per adapter: - Fastly — platform client IP; Axum — rightmost `X-Forwarded-For` entry - beyond required `trusted_proxy_hops` (absent config → socket peer only); - Cloudflare — `CF-Connecting-IP`; Spin — platform client address. - -### 5.5 Sink and deduplication - -- Stable event key `(publisher, trace_id, seq)`; deduplication at the sink - or query layer (Tinybird: latest-write or `GROUP BY` on the key), covering - fetch/pagehide double-delivery. +- **Rate limiting via an adapter abstraction** (the review is right that a + cross-request in-memory token bucket cannot exist on Fastly, `app.rs:146`): + trait `ClientEventLimiter` with a declared per-adapter backing and + semantics — Fastly: the platform edge counter (`rate_limiter.rs:40`), + fixed 60 s window, limit 20/window (documented approximation of + 10 rpm + burst 20); Axum: real in-process token bucket (10 rpm, burst + 20), map ≤ 65,536 entries; Cloudflare/Spin: per-isolate/per-instance + best-effort with the same parameters. **At capacity, unseen identities + are rejected (drop-and-count); active buckets are never evicted by + churn.** Limiter unavailable/errored → drop early with `204`. Trusted + client address per adapter: Fastly platform client IP; Axum rightmost + `X-Forwarded-For` beyond required `trusted_proxy_hops` (absent → socket + peer only); Cloudflare `CF-Connecting-IP`; Spin platform address. + +### 5.5 Sink, canonical views, and monitoring + +- Stable event key `(publisher_domain, trace_id, seq)`. +- **One named canonical dedup pipe/view per table** (`ts_client_events_v`: + latest `received_at` per key; `ts_render_attempts_v`: attempt-grain + aggregation keyed `(trace_id, nav_gen, refresh_gen, slot)`). **Joins run + at attempt/slot grain against the views, never raw-to-raw** (a raw join on + `(publisher_domain, trace_id)` multiplies rows). Dashboards and alerts + may query only canonical views. +- Field naming matches the existing auction rows: **`publisher_domain`**. - The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`) and - cannot observe downstream schema/auth rejection — therefore - **datasource-side freshness monitoring is mandatory** (§8 gates alarm on - ingestion lag and row-rejection metrics from the datasource side). + cannot see downstream rejection — **datasource-side monitoring is + mandatory**, driven by **sequence-tagged synthetic heartbeats** from a + probe client (accepted rows cannot reveal rejected rows): heartbeat gaps + measure rejection; heartbeat lag measures freshness. Alert owner: the + release owner's on-call. ### 5.6 Physical schemas (deployed before writers) -- **New datasource `ts_client_events`** (flattened rows): - `ts (DateTime64), publisher (LowCardinality String), release_id (String), -trace_id (FixedString 32), mode (Enum sampled|diagnostic), nav_gen UInt32, -refresh_gen UInt32, seq UInt32, event (Enum §5.1), slot (String ≤64), -id_kind (Enum), matched (UInt8), source (Enum), reason (Enum §5.1)`. - Sorting key `(publisher, ts, trace_id, seq)`; 30-day TTL; its own ingest - token, configured via new `TinybirdSettings` fields - (`client_events_dataset`, `client_events_token_secret`) — today's settings - configure only the auction dataset (`settings.rs:1752`). Sink batch cap: - 512 rows per dispatch (matching the auction sink). Startup validation: - when client events are enabled, the dataset name and token secret must - resolve or startup fails. Sink-unavailable behavior at runtime: - accept-count-drop (ingest still answers `204`). Canonical join: - `ts_client_events` ⋈ auction rows on `(publisher, trace_id)`; dashboards - and alerts are owned by the release owner and defined with the datasource. -- **Auction rows** (`AuctionEventRow`, `telemetry.rs:262`, and - `auction_events_raw.datasource`): add nullable `trace_id (FixedString 32)` - and `mode`; add a bounded **`bid_drop` row type** - `{provider, slot, reason (Enum), width UInt16?, height UInt16?, count -UInt32}` with per-auction row cap 32 and an `overflow` bucket row. -- APS parsing returns a **structured drop observation** - `{reason, slot, width?, height?}` instead of a bare reason string - (`aps.rs:722`); dimensions above 8192 use `dimensions_out_of_range` with - dimensions omitted, never clamped. - -### 5.7 Modes and SLOs - -- **Production (sink-backed only):** server-decided 10% sampling. - Two separated objectives: **pipeline availability** — ingestion freshness - ≤ 5 min and datasource rejection rate < 0.1%, alarmed on breach (fails - during sink outages, by design); **failure detection** — a failure mode - affecting ≥ 1% of sampled render attempts is visible within one hour, - evaluated only at ≥ 10,000 sampled render attempts/hour. -- **Diagnostic:** authenticated mode (§5.3), unsampled, full stream + - console mirroring; one page load names the failing class. +- **`ts_client_events`**: `received_at DateTime64, publisher_domain +LowCardinality(String), release_id String, trace_id FixedString(32), +mode Enum(sampled|diagnostic), nav_gen UInt32, refresh_gen UInt32, +seq UInt32, event Enum(§5.1), slot Nullable(String), id_kind +Nullable(Enum), matched Nullable(UInt8), source Nullable(Enum), reason +Nullable(Enum), outcome Nullable(Enum), dropped Nullable(UInt32)`. + Sorting key `(publisher_domain, received_at, trace_id, seq)`; TTL + 30 days; own ingest token; sink batch cap 512 rows; startup validation of + dataset + token when enabled ("startup" on request-bound platforms such + as Cloudflare means first-request lazy initialization with a cached + result); sink-unavailable at runtime → accept-count-drop. +- **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`): + add nullable `trace_id`, `mode`; add two bounded row types — **`bid_drop`** + `{provider, slot Nullable, reason Enum, width Nullable(UInt16), height +Nullable(UInt16), count UInt32}` (nullable slot/dimensions for + response-level failures; cap 32 rows/auction + overflow row) and + **`selection_summary`** per slot + `{slot, winner_source Enum(mediator|direct|none), winner_provider, +candidates_direct UInt16, candidates_mediator UInt16, dedup_hits, +currency_rejected, provenance_invalid, mediator_superseded}` (cap 8 + rows/auction + overflow) — §6.1's selection report now has a physical + home. +- **Settings schema (complete):** `[telemetry.client_events]` `enabled`, + `sample_rate` (0–1), `dataset`, `token_secret`; `[telemetry.trace_auth]` + `secret_store`, `active_kid`, `previous_kids = []`; the diagnostic + credential secret alongside. `RuntimeServices` (`platform/types.rs:158`) + gains the client-events sink handle next to the auction sink. +- APS parsing returns structured drop observations + `{reason, slot, width?, height?}` (`aps.rs:722` today loses slot and + values); >8192 → `dimensions_out_of_range`, dimensions omitted. + +### 5.7 Modes and SLIs + +- **Production (sink-backed only):** server-decided sampling + (`sample_rate`, default 0.10; `sampled` vs `unsampled` signed per trace). + Separated SLIs: **pipeline availability** (heartbeat freshness ≤ 5 min, + heartbeat loss < 0.1%; fails during sink outages, alarmed); **failure + detection** (a failure mode affecting ≥ 1% of sampled render attempts + visible within one hour, evaluated at ≥ 10,000 sampled attempts/hour). +- **Diagnostic:** credential-gated (§5.3), unsampled, full stream, console + mirroring, plus the `boot.debug` / response-`debug` envelopes (§2.5) — one + page load names the failing class for every §2 row. ### 5.8 Server-side drop surfacing -Bounded structured summary whenever any bid is dropped; `bid_drop` rows -(§5.6); drop summary in the initial-HTML `ts-debug` comment; page-bids gains -a tester-gated structured `debug` field. Startup warnings: APS + -`allow_script_creatives = false`; mediator + direct providers without an -explicit `winner_selection` (§6.1 makes that a hard error). +Bounded structured summary whenever any bid is dropped; `bid_drop` + +`selection_summary` rows; `ts-debug` comment carries the drop summary; +page-bids and `/auction` carry the gated structured `debug` field. Startup +warnings: APS + `allow_script_creatives = false`; mediator + direct +providers without an explicit `winner_selection` (§6.1 hard error). ## 6. APS delivery fixes -### 6.1 Mediation: complete inline algorithm - -Current mediation cannot support merging: it forwards no stable candidate id; -restores fields via a lossy last-write-wins `(provider, slot, bidder)` index -(`adserver_mock.rs:95`); breaks equal-price ties by response arrival order -(`orchestrator.rs:827`); and assigns parsed Prebid bids USD without -validating response currency (`prebid.rs:2318`). The algorithm below replaces -that, identically in the synchronous and split dispatch/collect paths, via -one shared candidate-selection helper. - -1. **Candidate registration.** Every direct-provider bid admitted by parsing - becomes a candidate with a **server-minted candidate id** (`c` + - 11-char CSPRNG, unique per auction). The full candidate (renderer, cache - coordinates, notification URLs, currency, provenance - `(provider, upstream_bid_id)`) is stored by candidate id. Winners are - selected **by candidate id** and their fields read from the stored - candidate — the lossy index is deleted. -2. **Currency.** The auction has one configured currency. A provider response - that declares another currency, or a path that cannot prove its currency - (the Prebid parse point must validate, not assume USD), rejects that bid - at parse with `bid_drop{currency_mismatch}`. No conversion. -3. **Mediator exchange.** Forwarded candidates carry their candidate id; the - mediator is required (wire contract, including `adserver_mock`) to echo it - on any bid derived from a forwarded candidate. A mediator bid **without** - an echoed id is mediator-native. A mediator bid with an id that does not - resolve → **the slot fails closed** for merging - (`mediation_provenance_invalid`: mediator-native bids for that slot still - compete; unresolvable forwarded claims are discarded and counted — a - warning alone is insufficient). -4. **Floors.** Slot floors filter both populations before selection. -5. **Dedup.** A mediator bid that echoes candidate id X removes direct - candidate X from the pool (it is the same demand, provenance `mediator`). -6. **Selection.** Per slot, the winner is the maximum under the **total - deterministic order**: decoded CPM desc → provenance rank (mediator - before direct) → provider name asc → candidate id asc. Response arrival - order can never matter. -7. **Strategy config.** `[auction].winner_selection` is **required whenever a - mediator and direct providers coexist** — startup error if absent (no - silent default; §0 removes the compatibility rationale for one): - `mediator_only` (mediator bids only, direct providers are signal) or - `merge_highest_cpm` (the algorithm above). A mediator timeout degrades to - direct-only selection and is reported. -8. **Reporting.** A selection report per auction: `winner_source`, - `mediator_superseded`, `currency_mismatch`, `dedup_hits`, - `mediation_provenance_invalid` — separate from delivery `bid_drop` rows. - -Deal priority remains out of scope: the `Bid` model carries no deal identity -(`types.rs:231`); a rule the model cannot express would be fiction. Recorded -as follow-up requiring a bid-model extension. +### 6.1 Mediation: complete, arrival-independent algorithm + +Current code cannot merge: no forwarded candidate id; lossy +last-write-wins `(provider, slot, bidder)` restoration +(`adserver_mock.rs:95`); arrival-order ties (`orchestrator.rs:827`); Prebid +assumes USD (`prebid.rs:2433`) and APS stamps USD (`aps.rs:475`) with no +configured currency. Replacement, identical in the synchronous and split +dispatch/collect paths via one shared helper: + +1. **Currency.** New required field `[auction].currency` (ISO 4217). Every + provider parse validates its response currency against it (absent + declaration where the provider contract implies one — APS's USD — is + validated as that implied value); mismatch → `bid_drop{currency_mismatch}`. + No conversion. +2. **Candidates.** Every admitted bid — direct **and mediator-native** — + becomes a candidate. Identity is two-part: `source_candidate_id` = + the **intrinsic stable key** `(provider_name, upstream_bid_id)` (for + mediator-native bids: the mediator's provider name and its bid id), and + `candidate_id` = a server-minted opaque wire id (`c` + 11-char CSPRNG) + used **only** for the mediator echo — never for ordering, so response + arrival order cannot influence selection. +3. **Mediator exchange.** Forwarded candidates carry `candidate_id` in the + named wire extension **`ext.trusted_server.candidate_id`** (contract for + every mediator implementation, `adserver_mock` included); the mediator + echoes it on derived bids. Echoed id resolves → the bid is the forwarded + candidate with provenance `mediator`; **authoritative fields:** price + and deal fields come from the mediator (repricing is its job); + render-source fields (renderer, adm, cache coordinates, notification + URLs) come from the stored candidate — a mediator that returns its own + `adm` for an echoed candidate is treated as mediator-native demand + instead. Unresolvable echoed id → the slot **fails closed for merging** + (`mediation_provenance_invalid`; mediator-native bids for the slot still + compete; the claim is discarded and counted). +4. **Floors** filter both populations. **Dedup:** an echoed candidate + removes its direct twin. +5. **Selection order (total, intrinsic):** decoded CPM desc → provenance + rank (mediator first) → `source_candidate_id` asc. Winner fields are + read from the stored candidate per rule 3. +6. **Strategy.** `[auction].winner_selection` is **required** whenever a + mediator and direct providers coexist (startup error if absent): + `mediator_only` or `merge_highest_cpm`. **Timeout behavior is + strategy-specific:** under `merge_highest_cpm`, mediator timeout → + direct-only selection, reported; under `mediator_only`, mediator timeout + → **no winners** (direct bids stay signal-only) unless + `mediator_timeout_fallback = "direct"` is explicitly configured. +7. **Reporting:** the `selection_summary` row (§5.6) per slot. + +Deal priority stays out of scope (the `Bid` model carries no deal identity; +recorded follow-up). ### 6.2 Dimensions -Exact size membership stays (`aps.rs:657-668`). The fix is visibility -(structured `bid_drop{invalid_dimensions, w, h}`, §5.6) plus documentation -("sizing your slots for APS"): if a size is acceptable, request it in the -slot's `formats` — accepting unrequested sizes would conceal an upstream -protocol violation. +Exact size membership stays (`aps.rs:675`). Fix is visibility +(`bid_drop{invalid_dimensions, w, h}`) plus documentation: request the +sizes you accept. ### 6.3 Script creatives -`allow_script_creatives` stays default-`false` (defensible sandbox posture); -the consequence becomes loud (§5.8) and the enablement path documented. +Default stays `false`; consequence loud (§5.8); enablement documented. ### 6.4 Render identity -As G2, including the `(trace_id, nav_gen, refresh_gen)` registry scope and -capacity rules. +As G2 (including tombstones). ### 6.5 Fallback @@ -566,80 +637,60 @@ render. ### 6.6 Renderer endpoint -- The static renderer document route registers **unconditionally in every - adapter** (the APS provider stays config-gated); startup validation fails - if an auth handler pattern covers it; §G5 route-isolation rules apply - (early dispatch, all methods reserved, no publisher fall-through). -- Path `/integrations/aps/renderer/v1`, embedded in the binary, served - `Cache-Control: immutable` (its bytes change only by shipping `/v2` in a - new release; §0's purge retires the old). Unknown versions → `404` +- Route registers unconditionally in every adapter (provider stays + config-gated); startup validation fails if an auth handler pattern covers + it; §G5 isolation rules apply. +- Path `/integrations/aps/renderer/v1`, embedded, served + `Cache-Control: public, max-age=31536000, immutable`; canary versions + are `no-store` (or bounded below the cohort lifetime); a **checked-in + header manifest per renderer version** freezes headers (CSP included) + with the bytes — a version's headers never change after publication, + resolving the immutable-caching/CSP conflict. Unknown versions → 404 `no-store`. -- **Two-stage acknowledgement:** authenticated `document_loaded` (proves - route + auth + document CSP), then the runner-load result — splitting - `renderer_document_no_load` from `runner_no_load`/`runner_failed`. -- Server route counters (requests, unknown-version, auth-blocked) are - **aggregate only** — the document request carries no trace (the nonce - rides the URL fragment and never reaches the server). -- **CSP rollout that cannot false-pass.** Three distinct uses, each with its - own instrument: **discovery** uses the **currently enforced** policy with - reporting attached (report-only alone cannot reveal what the enforced - policy already blocks); **tightening** candidates run report-only; - **relaxation** candidates are tested in a small enforced cohort under a - short-lived canary document version, gated on runner acceptance rate, CSP - violation rate, render-failure rate, and a kill switch that reverts the - cohort to the frozen policy. Once frozen, a new immutable `/v2` ships with - the final enforced headers. Reports: dedicated `POST /_ts/csp-reports` - accepting **both** `application/csp-report` (legacy) and - `application/reports+json` (Reporting API), each with its own payload - validator; §5.4 caps and rate-limit rules; **origin rules account for the - renderer's opaque sandbox** (reports may carry `null` origin — validated - by document URL / policy version instead of the beacon's same-origin - rule); unused report fields are **discarded before logging or - aggregation**; stored as aggregate counters with blocked sources bucketed - into `https-host (allowlisted) | data | blob | inline | eval | other` (no - arbitrary host labels — cardinality abuse is otherwise trivial). Browser - coverage for opaque-renderer reports runs on **Chromium, Firefox, and - WebKit** (CI is Chromium-only today, `playwright.config.ts:16`; the matrix - extends for this suite). - -### 6.7 One descriptor schema — generation covers all three implementations - -- Wire truth: the tagged `BidRenderer` envelope (discriminator on the enum, - `types.rs:188-211`). -- A wire-schema crate/xtask (separate from `trusted-server-js`; core already - depends on that crate, `Cargo.toml:45`) generates: the JSON-Schema - artifact, the **TS structural parser**, the **ES5-compatible inline - validator fragment** embedded in the renderer document, and shared - fixtures — all checked in with staleness CI. Only environment-specific - semantic checks (URL/origin policy, canonical base64, length bounds, the - exact one-bid AAX projection, cross-field equality) stay handwritten. -- Tolerance only on the outer versioned descriptor; the decoded AAX envelope - remains an exact projection. A shared positive + adversarial corpus runs - through the Rust validator, the generated TS parser, and the generated - inline fragment in CI. +- Three-message acknowledgement per G4b (`renderer_document_loaded` is the + first authenticated envelope). +- Server route counters are aggregate (the nonce rides the URL fragment). +- **CSP rollout:** discovery on the currently enforced policy with + reporting; tightening via report-only; relaxation via a small enforced + cohort on a short-lived canary version, gated on runner acceptance, CSP + violation rate, and render-failure rate, with a kill switch — and **CSP + reports are advisory**: for opaque-origin reports the body-supplied + document URL and policy version are forgeable, so **policy identity is + encoded in a server-selected report path** + (`POST /_ts/csp-reports/`, ids server-generated per version), + reports are bucketed into closed effective-directive buckets and + `https-host (allowlisted) | data | blob | inline | eval | other` source + buckets with global and per-cohort caps, unused fields discarded before + logging, and CSP data is **never a sole automatic rollback signal**. + Physical storage: aggregate counters only. Browser capture on Chromium, + Firefox, and WebKit (CI is Chromium-only today, + `playwright.config.ts:16`), both report media types + (`application/csp-report`, `application/reports+json`) with separate + validators. + +### 6.7 One descriptor schema + +Wire truth is the tagged `BidRenderer` envelope (`types.rs:188-211`). A +wire-schema crate/xtask (no `core → js` cycle; core already depends on the +js crate, `Cargo.toml:45`) generates the JSON-Schema artifact, the TS +structural parser, the ES5 inline validator fragment, and shared fixtures — +checked in, staleness-gated. Semantic checks (URL/origin policy, canonical +base64, bounds, exact one-bid AAX projection, cross-field equality) stay +handwritten. Outer-descriptor tolerance only; AAX projection exact. Shared +positive + adversarial corpus runs through all three validators in CI. ### 6.8 Bridge hardening -Processing order (normative — preserves the existing stolen-capability -defense that suppresses propagation before source validation, -`gpt/index.ts:1547`): - -1. parse `e.data` (bare `catch` → return); -2. identify a TS-reserved ad id (registry lookup); -3. if TS-reserved: `stopImmediatePropagation()` **before** any validation — - a rejected foreign frame must not be answerable by Prebid's native - handler either; -4. validate source ownership via the bounded walk: known slot-root - `WindowProxy` map, sender's own parent chain (`event.source.parent`, …) - to depth 5 — never scanning an attacker-controllable frame tree; -5. validate nonce, token, `nav_gen`, `refresh_gen` (G4b); -6. respond, or refuse with `bridge_id_mismatch`. - -Non-TS ad ids are untouched (no propagation suppression). The stolen-token -browser test asserts **neither TS nor the native Prebid listener responds**; -listener-registration ordering has a real-browser assertion. The dead -duplicate renderer branch (C5) is deleted; renderer branches emit the full -§5.1 sequence with G4d notifications only on carrying paths. +Order (normative; preserves the baseline defense that suppresses +propagation before source validation, `gpt/index.ts:1584-1637`): parse → +identify TS-reserved id (live registry **or tombstone**, G2) → +`stopImmediatePropagation()` → validate source ownership via the bounded +walk (known slot-root `WindowProxy` map; sender's parent chain to depth 5; +never scanning the frame tree) → validate nonce/token/`nav_gen`/ +`refresh_gen` → respond or refuse (`bridge_id_mismatch`). Non-TS ids are +untouched. The stolen-token browser test asserts neither TS nor native +Prebid responds; listener-order has a real-browser assertion. Renderer +branches emit the full §5.1 sequence; notifications only on carrying paths. ## 7. TSJS target architecture @@ -649,311 +700,295 @@ duplicate renderer branch (C5) is deleted; renderer branches emit the full kernel/ boot, config, queue, event bus, log, beacon, sessions adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access services/ slots (registry+handoff), auction client, render engine, consent -integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services) +integrations/ gpt, prebid, aps, creative, datadome, … ``` -Boundary lint in CI (`import/no-restricted-paths`): kernel imports nothing -above it; adapters import kernel only; services import kernel + adapters; -integrations import kernel + services, never each other. This dissolves the -audited inversions (`core/auction.ts` and `core/request.ts` importing -`integrations/aps/render`; `gpt` and `prebid` importing `aps`; `prebid` -owning the GPT refresh wrapper). Stateful services via the G3 registry only. +Enforced by the two G3 lint rules. Dissolves the audited inversions +(`core/auction.ts`/`core/request.ts` → `integrations/aps/render`; +`gpt`/`prebid` → `aps`; `prebid` owning the GPT refresh wrapper). ### 7.2 Adapters -Per external global: `present | pending | timed_out`, `timed_out` -non-terminal (late loaders transition to `present` and drain what is still -valid); queued operations carry their own timeouts and expire with +`present | pending | timed_out` per external global; `timed_out` +non-terminal; queued operations carry their own timeouts and expire with disposition reasons. ### 7.3 Slot registry service -Kernel-owned; `WeakMap` + div-id index; holds -ownership (ts/publisher/adopted), handoff claims, responsive resolution, -G4a intent queue + cycle state (RuntimeSession-scoped), targeting-key -history. No expandos on GPT objects (`__tsRenderGeneration`/`__tsRenderBid` -deleted). - -### 7.4 Final global surface (hard cutover — no dual names) - -| Legacy surface (removed at cutover) | Final shape | -| -------------------------------------------- | ----------------------------------------------------------------------------- | -| `window.tsjs.que` | `window.tsjs.que` — unchanged, the one public queue | -| `globalThis.tscreative` (API) | `tsjs.creative.*` (same methods, namespaced) | -| `globalThis.tsCreativeConfig` (pre-load) | `tsjs.boot.creative` inside the boot container (below) | -| `requestAds` (void) — and rev-4's dual API | **one** async contract: `tsjs.requestAds(options): Promise` | -| `window.__tsjs_*` flags, integration configs | `tsjs.boot.*` fields written by server-injected scripts before the bundle | -| server install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) | -| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` | -| `tsjs._internal` | kernel-owned registry (G3), **frozen after boot** | - -Boot container lifecycle: pre-core scripts write -`window.tsjs = window.tsjs || {que: [], boot: {}}` fields; the kernel -**consumes `boot` at boot, deep-freezes the retained copy, and deletes -consumed one-shot secrets** (the trace authorization moves into the sealed -NavigationSession). Old pages referencing removed names fail at cutover — -accepted per §0. +Kernel-owned; `WeakMap` + div-id index; +ownership, adoption, handoff claims, responsive resolution, the G4a causal +intent queue + cycle/drain state (RuntimeSession) and unissued intents +(NavigationSession), targeting history. No expandos +(`__tsRenderGeneration`/`__tsRenderBid` deleted). + +### 7.4 Final global surface (hard cutover) + +| Legacy surface (removed at cutover) | Final shape | +| --------------------------------------- | --------------------------------------------------------------------- | +| `window.tsjs.que` | `window.tsjs.que` — unchanged | +| `globalThis.tscreative` | `tsjs.creative.*` | +| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | +| `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) | +| `window.__tsjs_*` flags, config globals | `tsjs.boot.*` | +| install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) | +| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` | +| `tsjs._internal` | kernel registry (G3), frozen after boot | + +**Bootstrap correctness (closing the review's two holes):** every +server-injected initializer creates the container **idempotently and +field-wise** — `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}` — +never only-when-absent (today the first ad-slot script does +`window.tsjs = {}`, `publisher.rs:3665`, which would clobber or starve +later `boot` writes). Kernel boot claims an **atomic owner sentinel**; it +consumes `boot`, deep-freezes the retained copy, and deletes one-shot +secrets. The generated no-bundle fallback (§7.7) activates on bundle +`error` **or** a bounded hang watchdog (10 s without kernel boot); if the +fallback has activated and the bundle later arrives, the bundle **defers +for the rest of the page** (logs + `bundle_partial` disposition) — queue +ownership never changes hands mid-page. ### 7.5 Messaging module -All `postMessage` through one module: versioned envelopes, name constants -(the `'Prebid Request'` literal exists at six sites today; the APS handshake -in three copies), G4b nonces, §6.8 validation. The minimal module (envelope + -constants + validators used by the bridge) lands in Phase 1; full call-site -migration completes in Phase 4. +All `postMessage` through one module: versioned envelopes, name constants, +G4b nonces, §6.8 validation. Minimal module lands in Phase 1; full call-site +migration in Phase 4. ### 7.6 Plugin lifecycle — transactional — and sessions -`tsjs.definePlugin(id, install, dispose?)` with `install(ctx)`: - -- `ctx.signal` (aborted on quarantine/disposal); synchronous - `ctx.onDispose(fn)` registration; effects must be registered as they are - made. -- **Unwind on failure:** a throw, rejection, or abort triggers automatic - reverse-order invocation of the disposers registered so far — partial - installs cannot leak effects. Per-disposer exception isolation (one - throwing disposer cannot stop the rest). -- A disposer registered (or returned) **after** the owning session was - disposed is invoked immediately. -- Pending late registrations (manifest requested, bundle not yet evaluated): - capacity 16, bound 10 s, then `bundle_partial`. -- Release matching per G3: a plugin whose `release_id` differs from the - kernel's is quarantined before `install` runs. -- Sessions: `RuntimeSession` (page lifetime: bridge listener, history hook, - pbjs subscriptions, adapters, beacon queue, **slot cycle state**); - `NavigationSession` (per navigation: trace + authorization, render - attempts, slot aliases, targeting history); `RenderAttempt` (per G4a cycle - or G4f attempt). Each owns an enumerable disposal inventory; navigation - disposes only NavigationSession children. -- Error policy: no empty `catch` — handle, log with context, or emit a - disposition. The auction fetch gains timeout + `AbortController`. -- **Console logging retained:** every issue-surfacing condition keeps or - gains a `log.warn` carrying the same reason code as its beacon event; - `debug`-level delivery/security failures are promoted to `warn`. +`tsjs.definePlugin({id, release, install, dispose?})` — object form; the +`release` field is the build-generated `release_id` constant (G3 needs it; +revision 5's positional API omitted it). `install(ctx): void | +Promise`: + +- `ctx.signal`; synchronous `ctx.onDispose(fn)`; reverse-order unwind on + throw/reject/abort; per-disposer isolation; disposer registered after + disposal → invoked immediately; pending late registrations capacity 16, + bound 10 s → `bundle_partial`; release mismatch quarantines before + `install`. +- Sessions: `RuntimeSession` (page-lifetime: bridge listener + tombstones, + history hook, pbjs subscriptions, adapters, beacon queue, physical slot + cycle/drain state); `NavigationSession` (trace + authorization + renewal + timer, render attempts, slot aliases, unissued intents, targeting + history); `RenderAttempt` (per G4a cycle / G4f attempt). Enumerable + disposal inventories; navigation disposes NavigationSession children + only. +- No empty `catch`; auction fetch gains timeout + `AbortController`. +- **Console logging retained**: every issue-surfacing condition keeps or + gains a `log.warn` with the beacon's reason code; `debug`-level + delivery/security failures promoted to `warn`. ### 7.7 Bootstrap -`gpt_bootstrap.js` (495 ES5 lines duplicating handoff/initial-load/hydration -logic, with the live `servicesEnabled` divergence) shrinks to a -queue-and-flags stub; the bundle replays recorded early calls on install. -Replay changes observable ordering — it ships inside the cutover with -browser specs covering replay timing. The no-bundle fallback ("ads render if -the bundle fails", pinned by `gpt.rs:1174-1179`) is **generated from the -same TypeScript source** at build time. +`gpt_bootstrap.js` shrinks to a queue-and-flags stub; the bundle replays +recorded calls on install (browser specs cover replay timing); the +no-bundle fallback is **generated from the same TypeScript source**, with +the §7.4 activation/arbitration rules. ### 7.8 GPT correctness carried with the restructure -Unconditional early `slotRequested`/`slotRenderEnded` subscription (replacing -the `!servicesEnabled` gate, `gpt/index.ts:1017`; recording idempotent); -restore #922/#997 attribution and orphan recovery; `changeCorrelator: false` -on TS refreshes (configurable); `enableSingleRequest()` only when GPT -services are not already enabled; ambiguous responsive resolution emits -`render_fail{slot_unresolved}` alongside its warning. +Unconditional early `slotRequested`/`slotRenderEnded` subscription +(replacing the `!servicesEnabled` gate, `gpt/index.ts:1091`; idempotent +recording); restore #922/#997 attribution and orphan recovery; +`changeCorrelator: false` on TS refreshes (configurable); +`enableSingleRequest()` only when services are not already enabled; +ambiguous responsive resolution emits `render_fail{slot_unresolved}`. ### 7.9 Decomposition targets | Today | Target | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | +| `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | | `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics | | `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | -| `core/trace.ts` (model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | +| `core/trace.ts` (model + UI) | `services/trace` + `integrations/trace_overlay` | | `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal | ### 7.10 Performance (reproducible) -- Bundle budgets: raw/gzip/Brotli per bundle for **three module vectors** - (minimal, reference, maximal), compressors pinned (`gzip -9`, - `brotli -q 11`), compared to checked-in baseline artifacts - (`perf/baselines/*.json`); baseline updates are explicit reviewed diffs. - Tolerance +5% bytes. -- Browser timing (bids-script-to-first-`display()`): named runner image - `ubuntu-24.04` (the CI image already used by this repository's workflows) - and the Chromium build bundled with the pinned `@playwright/test` version - from `package.json`; 5 warm-up runs discarded, 50 samples, gate p90 ≤ - baseline × 1.10. The baseline artifact records image, browser, and tool - versions alongside the numbers; a baseline update is invalid if any of - those differ from the pinned set. -- Server (precomputed concatenation): one-sided gates, CPU and heap ≤ - baseline × 1.10; improvements always pass. Tool versions pinned in - `.tool-versions`. +- **Dedicated workflow** on a fixed runner (`runs-on: ubuntu-24.04` + explicitly — browser CI is `ubuntu-latest` today, + `integration-tests.yml:155` — inside a pinned container image digest); + browser = the lockfile-resolved `@playwright/test` build with its browser + revision recorded in the baseline artifact (the manifest is a caret + range today, `browser/package.json:10` — the lockfile + recorded + revision are authoritative); compressors pinned by version in the + container (`gzip -9 -n` for determinism, `brotli -q 11`). +- Bundle budgets: raw/gzip/Brotli for three vectors (minimal, reference, + maximal) vs checked-in baselines (`perf/baselines/*.json`, updates are + reviewed diffs recording image/browser/tool versions); +5% bytes. +- Browser timing: 5 warm-ups discarded, 50 samples, p90 ≤ baseline × 1.10. +- **Server benchmark harness (complete):** workload = concatenation + + hash of the reference vector; 100 warm-up iterations, 1,000 measured; + statistic = median and p90; one-sided gates ≤ baseline × 1.10; variance + policy: 3 consecutive runs must agree within 5% or the result is + inconclusive (rerun, never pass). ### 7.11 Toolchain -Raise the TypeScript floor to the resolved 5.9 line (lockfile already -resolves 5.9.3 under the stale `^5.5.4` manifest); adopt -`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, -`verbatimModuleSyntax`; dev-toolchain bumps (eslint, prettier, jsdom, -`@playwright/test`, `@types/node`) as individual CI-gated PRs with changelog -review (this library monkeypatches `fetch`/`sendBeacon`/DOM prototypes — -jsdom/Playwright changes are real risks); `prebid.js` excluded from casual -bumps (runtime Prebid is the manifest-locked external bundle; the npm pin -and deployed bundle version documented together); monthly review; no phase -starts more than one minor behind stable. - -## 8. Migration plan - -Phases are internal build milestones of **one coordinated release** (§0): -each lands behind a flag in the dark deployment; the cutover switches them -on together. Gates are executable — every gate names query, cohort, -denominator, minimum sample, threshold, window, owner (release owner unless -stated), and action (hold cutover / rollback switch): - -- **Phase 0 — Identity, schemas, toolchain.** Path-hashed embedded assets + - 410 semantics + ordered-vector precompute; `format_version`; §5.6 schemas - deployed and validated (writer-off); toolchain floors; dead expando writes - deleted; §5.8 server drop surfacing. - _Gate:_ dark-instance health 100%; datasource validation green (rejection - rate < 0.1% on synthetic writes, freshness ≤ 5 min); asset `410` rate on - dark probes = 0 for known hashes. +TypeScript floor to the resolved 5.9 line; strictness flags on; dev +toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from +casual bumps; monthly review. + +## 8. Rollout: phases, decision records, and executable gates + +Phases are build milestones inside the §0 single-release model (dark pool → +probe gates → router-weight canary of coherent requests → full weight). + +**Phase 0 decision records** (promoted from open questions; each has an +owner, evidence, a deadline, and an explicit go/no-go): DR-1 mediator +presence in the affected deployment (OQ1 — decides whether §6.1 gates +Phase 3 entry); DR-2 script-creative share (OQ2 — decides the §6.3 +guidance priority); DR-3 #922 vs #997 (OQ4 — decides the Phase 3 work +item); DR-4 mediator candidate-id echo owner and timeline (OQ7 — +`merge_highest_cpm` is config-blocked until delivered); DR-5 non-Fastly +sink decision (OQ5 — splits Phase 2's gates below). + +**Gates are a checked-in table** (`docs/superpowers/specs/rollout-gates.md`, +created in Phase 0) with columns: query/test command, assignment key, +expected positive count, denominator, sample floor, threshold, window, +owner, hold/rollback action. The real-GAM suite's row includes its workflow +name, fixture account and credentials owner, invocation command, artifact +location, retry policy, and required approval evidence. Prose below is the +summary; the table is normative. + +- **Phase 0 — Identity, schemas, toolchain, decisions.** Path-hashed + assets + 410 semantics + construction-time concatenation cache; + `format_version`; §5.6 schemas deployed writer-off; toolchain floors; + dead expando writes deleted; §5.8 drop surfacing; the five decision + records; the gates table itself. - **Phase 1 — Kernel, sessions, minimal messaging, cycle registry.** G3 - registry (exact release ids); RuntimeSession/NavigationSession; install - manifest; minimal messaging module; G4a intent/cycle records; unconditional - GPT subscriptions. - _Gate:_ browser-spec suite green incl. listener-order assertions; zero - `abi_mismatch`/`bundle_partial` on dark probes. -- **Phase 2 — Trace + beacon.** Server-minted initial trace + page-bids - header echo + signed authorizations; beacon service; four-adapter ingest; - `ts_client_events` writers on. - _Gate:_ on dark probes: ingest acceptance ≥ 99%, group auth-rejection - < 0.5%, dedup query returns exactly-once per `(trace, seq)`; freshness - ≤ 5 min over a 24 h window. -- **Phase 3 — APS delivery.** Schema crate + corpus (6.7); mediation - algorithm + required `winner_selection` (6.1); render token (G2); - renderer route + two-stage ack + CSP report route (6.6); bridge order - (6.8); G4a–G4f state machines, awaitable renderer, scoped notifications, - fallback; #922/#997 restoration; correlator + SRA fixes. - _Gate (canary cohort vs simultaneous control cohort, 24 h, minimum 10,000 - sampled attempts each):_ APS `render_accepted` / attributable APS attempts - ≥ 95%; renderer-document load rate ≥ 99%; runner failure+timeout ≤ 1%; GAM - fill, p95 latency, and billing volume deltas within ±2% of control - (billing measured against GAM/server-side reporting, not the beacon); - real-GAM overlap test green. Action on breach: hold cutover. -- **Phase 4 — Structure.** Full layering + boundary lint; transactional - plugin lifecycle; adapters; full slot registry; full messaging migration; - final namespace (7.4). - _Gate:_ boundary lint zero exceptions; disposal-inventory leak tests - green; pre-cutover page smoke on the final namespace; **behavioral-parity - suite green across all four flows** (SSAT, client-Prebid, page-bids, - direct `/auction`) comparing pre- and post-restructure event sequences on - the reference page. + registry; sessions; install manifest; minimal messaging; G4a intent/cycle + records; unconditional GPT subscriptions. +- **Phase 2 — Trace + beacon.** G1 issuance on all three paths (boot, + page-bids, `/auction` extension); §5.3 authorization incl. renewal and + the diagnostic credential; four-adapter ingest; `ts_client_events` + writers on. Gates split per DR-5: **HTTP parity** (all adapters: + routing, limits, 204s, method reservations) vs **persistence** + (sink-backed only: acceptance, dedup-exactly-once, heartbeat freshness). +- **Phase 3 — APS delivery.** Schema crate + corpus; §6.1 with required + `winner_selection` and `[auction].currency`; render token + tombstones; + renderer route + three-message ack + CSP report route; §6.8; G4a–G4f + incl. direct-flow `nurl`/`burl` plumbing; fallback; DR-3's attribution + restoration; correlator + SRA fixes. + _Gate (sticky randomized canary/control cohorts, 24 h, ≥ 10,000 sampled + attempts each; missing telemetry counts as failure):_ **denominator = + all server-observed eligible APS wins**; per-stage rates gated + separately — targeting_set/eligible, bridge_request/targeting_set, + render_accepted/bridge_response_sent, and `cycle_unattributable` rate + < 0.5% (survivorship is thereby visible, not excluded); GAM fill and p95 + latency as **one-sided non-inferiority** (canary not worse than control + by > 2%; improvements pass); billing normalized per attempt and per + thousand attempts vs control; a separate **duplicate-billing invariant** + (zero double `burl` per idempotency key); real-GAM overlap suite green + per its table row. +- **Phase 4 — Structure.** Full layering + both lint rules; plugin + lifecycle; adapters; full slot registry; full messaging migration; final + namespace. + _Gate:_ lints zero exceptions; disposal-inventory leak tests; four-flow + behavioral parity (SSAT, client-Prebid, page-bids, direct). - **Phase 5 — Decomposition + cutover.** File splits; script-guard - consolidation; bootstrap stub + generated fallback; then the §0 runbook. - _Gate:_ bundle budgets + timing assertions hold; cutover checklist signed - off; post-switch monitor window 24 h on the §5.7 objectives; rollback = - traffic switch back. + consolidation; bootstrap stub + generated fallback (with error/hang/ + fallback-arbitration tests); **four-flow parity reruns here** (it changed + bootstrap behavior after Phase 4's parity — the review's ordering + point); then the §0 runbook: weight-up, purge, 24 h monitored window, + weight-back rollback. ## 9. Test acceptance matrix -Hermetic CI (deterministic PUC/message harness) blocks PRs; the staged -real-GAM suite is release-gating. Rows added this revision are marked •. - -| Area | Must cover | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Request cycles | intent-vs-request separation; • initial-load-disabled cycle formation (display creates no request); SRA batching; `intent_no_request`; publisher overlap → quarantine; • old-navigation completion before and after replacement; drain/re-arm; real-GAM overlap (gate) | -| Ack protocol | five-field validation; SSAT/client-Prebid/SafeFrame; stale + replayed acks; • acks after navigation disposal | -| Bridge security | • TS-reserved id rejected with propagation stopped — neither TS nor native Prebid responds; wrong-slot/stolen/prior-navigation tokens; bounded parent-chain walk; listener-order real-browser assertion; SafeFrame positive | -| Render semantics | notifications only on carrying paths (never APS); bind per flow (PUC claim / direct render-start / fallback pre-render); `burl` at `render_accepted`; attempt-scoped idempotency; `billed_then_failed`; accepted-but-blank | -| Direct `/auction` | • full G4f lifecycle: attempt keys, refresh_gen increments, cancellation on navigation, exactly-once terminal, same event sequence | -| Fallback | only attributed `gam_empty` (adopted + TS-owned); publisher-initiated never; timeout never renders; SPA cancellation; • flag change during an active attempt | -| Mediation | • candidate-id echo; • transformed/unresolvable provenance → slot fails closed for merging; • duplicate candidates dedup; • deterministic ties independent of arrival order; currency validation at the Prebid parse point; both lifecycles; required `winner_selection` | -| Render token | format/CSPRNG/in-auction retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scoping; • registry capacity → `registry_full` | -| Trace auth | • HMAC verification: expiry, skew, max-future, missing kid, rotation (previous key), constant-time path; per-group rejection; cache-privacy invariant (`private, no-store`) | -| Beacon | initial + SPA trace joins; per-trace grouping; seq gaps; • duplicate fetch/pagehide delivery deduped at sink; queue overflow marker; ingest abuse; sendBeacon Blob type | -| Ingest/limits | • token-bucket rate + burst; • limiter saturation and address churn; • map capacity/TTL/eviction; fail-closed drop with 204 | -| Internal routes | • wrong-method → 405 + Allow + no-store on every adapter; • unknown version → 404 no-store; • no publisher fall-through; • dispatch before auth/EC/filters; • no body/cookie/authorization forwarding | -| CSP | • both media types with separate validators; • opaque/null-origin renderer reports accepted; • bucketed aggregation only; • Chromium/Firefox/WebKit capture; • enforced-policy discovery vs canary-cohort relaxation | -| Schema | staleness; adversarial corpus through Rust + generated TS + generated inline fragment; outer tolerance vs exact AAX projection | -| Runtime ABI | one kernel under concatenation; exact-release verdicts (match runs, mismatch quarantines); late registration; failure isolation | -| Plugins | • partial synchronous install unwound in reverse order; • async rejection; • abort while pending; • disposer-after-disposal invoked immediately; per-disposer isolation | -| Lifecycle | `timed_out → present`; session disposal inventories; boot container consume/freeze/delete; final-namespace smoke (`tsjs.que`, `tsjs.creative`, async `requestAds`) | -| Delivery | unknown hash 410 no-store; immutable on exact match; ordered-vector precompute; cutover runbook rehearsal (switch + purge + rollback switch) | -| Sink | • datasource-side freshness + rejection monitoring (sink is fire-and-forget); • sink auth/schema rejection surfaced by monitor | -| Failure injection | • Amazon runner redirect, network hang, CSP block, script error → distinct §5.1 outcomes | -| Adapter parity | ingest, CSP-report, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | -| Policy | script-creative warning; `invalid_dimensions` + bounded w/h; `dimensions_out_of_range` unclamped; page-bids `debug` gating; diagnostic completeness | +Hermetic CI blocks PRs; the real-GAM suite is release-gating per its gates +row. New/changed rows this revision are marked •. + +| Area | Must cover | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Request cycles | intent-vs-request; • disabled-initial-load `display()` retired at issuance, publisher `refresh()` inside 2 s window attributed to publisher (the exact review case); SRA; `intent_no_request`; overlap quarantine; • no timeout re-arm (drain/destroy/page-end only); stale discard; real-GAM overlap | +| Ack protocol | three-message sequence (• `renderer_document_loaded` envelope); five-field validation; SSAT/client-Prebid/SafeFrame; stale/replayed; after-disposal acks | +| Bridge security | propagation stopped before validation; neither TS nor native Prebid responds to stolen ids; • prior-navigation ids suppressed via RuntimeSession tombstones after NavigationSession disposal; bounded walk; listener order | +| Render semantics | binds per flow; `burl` at accepted; attempt idempotency; • `billing_outcome{billed_then_failed}` as its own event; accepted-but-blank; • `gam_collapsed` emission + guarded resize (authenticated source only; 1×1 wrapper; anchor/fixed guards) | +| Direct `/auction` | • trace header + response `ext.trusted_server.trace`; • per-slot latest-wins with reversed responses; • generation check before each DOM/beacon effect; • `RequestAdsResult` settlement; • server preserves + expands `nurl`/`burl`, client validates | +| Fallback | attributed `gam_empty` only; publisher-initiated never; timeout never renders; SPA cancellation; flag change mid-attempt | +| Mediation | • `[auction].currency` required + per-provider validation (Prebid parse, APS implied USD); • `ext.trusted_server.candidate_id` echo; • mediator-native candidates ordered by intrinsic key (arrival-order shuffle test); • authoritative-field rules (repricing kept, adm-swap → native); provenance fail-closed; • strategy-specific timeouts; both lifecycles | +| Render token | format/CSPRNG/retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scope; capacity → `registry_full`; • tombstone retention to original TTL, cap 256 | +| Trace auth | • auth ≤ 256 B bound accepted, 64-char cap for others; • encoding vectors (kid charset, canonical exp, u32be/u64be length prefixes, unpadded base64url); expiry/skew/max-future; • renewal preserves trace + mode; • previous-key retention ≥ 24 h; rotation; per-group rejection | +| Sampling modes | • signed `unsampled`: client transmits nothing, ingest rejects carried events, stickiness across renewal; • diagnostic requires the operator credential — tester cookie alone must fail; forgery/replay of the credential | +| Beacon | joins on all three issuance paths; per-trace grouping; seq gaps; duplicate fetch/pagehide deduped in the canonical view; • `client_queue_overflow` not in failure denominators; ingest abuse; sendBeacon Blob | +| Ingest/limits | • per-adapter limiter semantics as declared (Fastly fixed-window approximation, Axum token bucket); • at-capacity rejects unseen identities, never evicts active; fail-closed 204 | +| Internal routes | wrong-method 405 + Allow + no-store on every adapter; unknown version 404; no publisher fall-through; dispatch before auth/EC/filters; no forwarding | +| CSP | both media types; opaque/null-origin admission; • policy identity from server-selected report path (forged body URL/version ignored); bucketed aggregation with caps; three-browser capture; • header manifest per version (immutable headers frozen with bytes) | +| Schema | staleness; adversarial corpus ×3 validators; outer tolerance vs exact AAX | +| Runtime ABI | one kernel; exact-release verdicts; late registration; failure isolation; • object-form `definePlugin` release check | +| Plugins | partial-install unwind; async rejection; abort pending; disposer-after-disposal; isolation | +| Lifecycle | `timed_out → present`; session inventories; • unissued intents cancelled by navigation disposal; boot container idempotent field-wise init (• ad-slot script no longer clobbers); • fallback error/hang activation + late-bundle deferral; final-namespace smoke | +| Delivery | unknown hash 410 no-store; exact-match immutable with full directive; • construction-time vector cache (unlisted vector = startup error); cutover rehearsal | +| Sink/monitoring | • sequence-tagged synthetic heartbeats measure rejection + freshness; • canonical views only (raw-join multiplication test); `publisher_domain` naming | +| Failure injection | Amazon runner redirect/hang/CSP/script error → distinct outcomes; EC/filter failure before renderer dispatch | +| Adapter parity | ingest, CSP-report, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | +| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; • `boot.debug` + response `debug` gating; diagnostic completeness per §2.5 | ## 10. Alternatives considered -1. **Patching APS point-failures without telemetry** — rejected: three - correct fixes produced no ads; the next fix would be another guess. -2. **Always direct-render APS (skip GAM/PUC)** — rejected: unilaterally - changes GAM reporting/pacing; kept only as the attributed-`gam_empty` +1. Patching APS point-failures without telemetry — rejected (four correct + fixes, still no reliable ads). +2. Always direct-render APS — rejected; kept as the attributed-`gam_empty` fallback. -3. **Single module graph / shared chunks now** — rejected for this release: - changes the delivery pipeline while everything else changes; recorded as - the successor behind the same registry surface. -4. **Full rewrite in one branch without phases** — rejected: the browser-spec - safety net is thinnest exactly where behavior changes. -5. **Dropping the ES5 bootstrap** — rejected: loses the pinned no-bundle - guarantee; generation from TS keeps it without dual maintenance. -6. **Timeout-triggered fallback** — rejected: GPT requests cannot be - cancelled; a timeout race can double-render and double-bill. -7. **N/N−1 compatibility machinery** (revisions 3–4) — removed by the §0 - policy decision: version ranges, retained artifacts, legacy URLs, and - dual-name globals deleted in favor of exact release matching and a - coordinated switch. +3. Single module graph now — rejected for this release; successor option. +4. Big-bang rewrite without phases — rejected (thin safety net). +5. Dropping the ES5 bootstrap — rejected (loses the no-bundle guarantee); + generated fallback keeps it. +6. Timeout-triggered fallback rendering — rejected (uncancelable GPT + requests race late fills). +7. Timeout-based quarantine re-arm (revision 5) — removed: it recreated + the stale-event bug it claimed to fix. +8. N/N−1 compatibility machinery (revisions 3–4) — removed by the §0 + policy. ## 11. Risks -- **Hard cutover blast radius:** in-flight pages fail at switch; accepted by - policy (§0); bounded by the purge + 24 h monitored window + traffic-switch - rollback. -- **Mediator wire-contract change** (candidate-id echo) requires - coordinating the mediator implementation; until echoed ids exist, - `merge_highest_cpm` cannot be enabled (config validation enforces this). -- **Notification triggers become a published contract** for PBS-path demand; - changing them later is a breaking change for SSP reporting. -- **Beacon abuse:** bounded by pre-parse caps, origin checks, fail-closed - numeric rate limits, server-decided sampling, signed authorizations. -- **Registry/limiter memory:** all client and server maps carry explicit - capacities, TTLs, and eviction rules (G2, §5.4). -- **CSP relaxation:** enforced-cohort canary + new immutable version prevent - false-clean canaries; bucketed aggregation prevents cardinality abuse. -- **Sink blindness:** fire-and-forget dispatch is compensated by mandatory - datasource-side freshness/rejection monitoring. +- Hard-cutover blast radius (accepted; bounded by the §0 runbook). +- Mediator wire-contract change (`candidate_id` echo) — DR-4 gates + `merge_highest_cpm`. +- Notification triggers become a published contract for PBS-path demand. +- Beacon abuse — capped, origin-checked, fail-closed limited, signed + modes, credentialed diagnostics. +- Registry/limiter memory — explicit capacities, TTLs, reject-at-capacity. +- CSP data is advisory — never a sole rollback signal. +- Sink blindness — heartbeat-based datasource monitoring. +- `[auction].currency` and `winner_selection` are new required config in + mediated deployments — a deliberate startup-error class under §0. ## 12. Success criteria -1. APS creatives render in each configured flow — SSAT, client-Prebid, - page-bids, and direct `/auction` (G4f) — hermetically in CI and in the - release-gating real-GAM suite. -2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the - failing class from one page load; §5.7 objectives hold on sink-backed +1. APS creatives render in each configured flow (SSAT, client-Prebid, + page-bids, direct), hermetically and in the release-gating real-GAM + suite. +2. Every §2 failure maps to its §2.5 signal; **diagnostic mode names the + failing class from one page load including A1–A4 via the `boot.debug` / + response-`debug` envelopes**; §5.7 SLIs hold on sink-backed deployments. -3. Boundary lint zero exceptions; stateful sharing only via the G3 registry; - exact-release mismatches quarantine loudly. +3. Lints (both rules) zero exceptions; stateful sharing only via the + registry; exact-release mismatches quarantine loudly. 4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or generated. -5. Trace counts are per-impression; orphan recovery has a non-vacuous test; - cycle attribution follows G4a (physical requests, publisher-overlap - quarantine, drain/re-arm). -6. The only TSJS-owned global is `window.tsjs` with the §7.4 final shape; no - expandos on GPT slots, GPT functions, or `pbjs`; legacy names are gone at - cutover. -7. §7.10 budgets hold (three vectors, pinned tools, one-sided server gates). -8. No existing warning is lost; every issue-surfacing condition logs `warn`+ - with the beacon's reason code. -9. TypeScript floor matches the resolved 5.9 line with strictness flags on; - `prebid.js` pin matches the documented deployed bundle. -10. `nurl`/`burl` fire only on carrying paths at their G4d binds, - attempt-scoped and idempotent; APS fires neither. -11. Trace-bearing responses are `private, no-store` by test; authorizations - are per-trace, signed, mode-carrying, and never accepted unsigned. -12. The cutover runbook has been rehearsed (switch, purge, rollback switch) - before the production switch. +5. Attempt counts are keyed `(trace_id, nav_gen, refresh_gen, slot)` + (traces stay navigation-scoped); no double counting; orphan recovery + has a non-vacuous test; G4a holds including the no-timeout-re-arm rule. +6. The only TSJS-owned global is `window.tsjs` (§7.4 final shape); no + expandos; legacy names gone at cutover. +7. §7.10 budgets hold on the dedicated pinned workflow. +8. No existing warning lost; issue-surfacing conditions log `warn`+ with + the beacon reason code. +9. TypeScript floor matches resolved 5.9; `prebid.js` pin documented with + the deployed bundle. +10. `nurl`/`burl` only on carrying paths at their G4d binds, idempotent + per attempt; APS fires neither; duplicate-billing invariant holds. +11. Trace-bearing responses are `private, no-store`; authorizations are + per-trace, signed, mode-carrying (`sampled|unsampled|diagnostic`), + renewal-capable; unsampled traces transmit nothing. +12. The cutover runbook rehearsed (weight switch, purge, rollback). ## 13. Open questions -1. Is a mediator configured in the affected production deployment? -2. What share of live APS demand is `tagtype: "script"`? -3. Should `client_render_fallback` ever become default-on for publishers - without GAM line items for `hb_bidder=aps`? -4. Is PR #997 the intended restoration of the lost #922 attribution core, or - should the original be re-merged? -5. Do Axum/Cloudflare/Spin get real client-event sinks, or keep - accept-count-drop? -6. Does Amazon expose any creative-completion acknowledgement that could add - a confirmed state beyond `render_accepted` under a new name? -7. Who implements and owns the mediator-side candidate-id echo (6.1), and on - what timeline relative to this release? +Promoted to Phase 0 decision records: mediator presence (DR-1), +script-creative share (DR-2), #922 vs #997 (DR-3), candidate-id echo owner +(DR-4), non-Fastly sinks (DR-5). Remaining open: does Amazon expose any +creative-completion acknowledgement that could add a post-`render_accepted` +state under a new name (future enhancement)? From 517471c9d38cda0da1ea97606e8faa41801a00c7 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:27:56 -0700 Subject: [PATCH 008/194] Revise design spec for the sixth review round Revision 7: renewal presents and verifies the current signed authorization so mode survives without server state, sampling becomes a deterministic keyed function, the diagnostic credential gets a complete issuance/transport/expiry protocol with the tester cookie scoped to debug content only, /_ts/trace-auth joins the isolated route families, sticky-cohort release affinity makes canary requests coherent end to end, the server auction_id is echoed into client events for attempt-grain joins with release_id as the cohort discriminator, a closed flow field drives per-flow funnel gates, the reservation store caps the live-plus-tombstone union and never evicts unexpired ids, zero-request display intents are retired for any caller with a symmetric ambiguity rule, the acknowledgement protocol is specified for all four render paths, bootstrap ownership becomes transactional with inert-install and commit, CSP ingest gets concrete bounds and physical schemas alongside ts_ops_counters, heartbeats become a first-class probe-mode event, telemetry settings split collection from sink with lazy secret initialization everywhere, mediation gains required-unique upstream ids with deterministic fingerprints and closes its authoritative-field contradictions, assets pre-materialize at release publication, notification_sent makes the duplicate-burl invariant observable, one terminal state per attempt, per-reason source nullability, gam_collapsed carries its action, the final surface adds definePlugin, lint moves to member-expression rules, performance gains marks/vectors/heap budgets, TypeScript flags are enumerated, mid-attempt kill-switch semantics are defined, DR-2 becomes a deployment decision, the Phase-3 gate reruns on the immutable Phase-5 candidate, and the normative gates table ships as Appendix A of this one file. The baseline APS fixes are adopted as contracts and re-implemented in the target architecture with their browser tests as the conformance pin. --- ...s-render-fix-and-tsjs-resilience-design.md | 1595 ++++++++--------- 1 file changed, 706 insertions(+), 889 deletions(-) diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index 0251485b9..001011d91 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1,994 +1,811 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 6 — baseline advanced to the APS PUC/collapsed-shell - fix, and reworked after the fifth review round. +- **Status:** revision 7 — reworked after the sixth review round. - **Date:** 2026-08-04 - **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed - GAM shells") — the full merged state. All file:line citations refer to this - commit. -- **Inputs:** three code audits; design reviews of revisions 1–5; open issues + GAM shells"). All file:line citations refer to this commit. +- **Inputs:** three code audits; design reviews of revisions 1–6; open issues #926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997. +- **Normative gates:** the initial rollout-gates table ships as + **Appendix A of this document** (one file, one review surface); changes to + it require reviewed decision records so thresholds cannot be chosen after + observing results. +- **Adoption stance for the baseline APS fixes (`248fe9558`):** this design + adopts their **contracts** — the MessageChannel handshake semantics, the + collapsed-shell remediation behavior, the consolidated bridge branch — and + **re-implements them inside the target architecture** (the messaging + module owns the channel protocol, the render engine owns the resize, the + rebuilt `render_bridge` module owns the branch). The patch code itself is + not carried forward through the refactor; the baseline's browser tests are + retained as the conformance suite that pins the adopted behavior while the + implementation is replaced. ## 0. Release policy: coordinated hard cutover -This design targets a **single coordinated release**: +One coordinated release: -- Server, TSJS bundles, config format, and page HTML ship together under one - **`release_id`** (git tag / build hash). **No N/N−1 support**: old pages, - bundles, config blobs, globals, and URLs may stop working at cutover; - in-flight clients may fail. Accepted and stated, not mitigated. -- **Exact release matching only** — kernel, services, plugins, and the - install manifest carry the same `release_id`; mismatch is a refusal. -- **Config:** top-level `format_version`, exact match required. Rollback = +- Server, TSJS bundles, config, and HTML ship under one **`release_id`**. + **No N/N−1**; in-flight clients may fail at cutover — accepted and stated. +- **Exact release matching**; config `format_version` exact-match; rollback = redeploy the previous release with its own config. -- **Assets:** binaries embed only their release's artifacts; hashed pathnames - exist for cache identity only; unknown hash → `410 Gone`, `no-store`. -- **One executable rollout state machine** (resolving the §0/§8 tension the - review found): a release ships with a **deployment manifest** enumerating - the complete flag set; the new pool comes up **fully enabled but - unreachable except by probes**; phase gates (§8) run against probe traffic - and a **router-weight canary of coherent routed requests** (a request is - served end-to-end by one pool — HTML, assets, and APIs never mix pools); - **router weight is the sole activation primitive**; cutover = weight to - 100% + CDN purge; rollback = weight back + re-purge. Flags exist for - emergency kill switches inside a pool, not as the activation mechanism. +- **Config is a release-time input** under this policy: the config blob and + binary publish together, so the enabled module vectors are known at + release publication (this powers §G5 asset materialization). +- Assets: embedded only; hashed pathnames for cache identity; unknown hash + → `410`, `no-store`. +- **Rollout state machine with release affinity.** A deployment manifest + binds each pool to immutable `{release_id, config_store, config_key, +config_hash}` (rollback binding prevalidated). The new pool comes up fully + enabled, reachable only by probes. Canarying uses a **sticky cohort + token**: the router assigns `ts-rel=` on the HTML response, + routes every subsequent request (assets, APIs, beacons) by it, and cache + keys include it — router weights alone apply per request and would mix + pools, so affinity is what makes a canary request **coherent** end to end. + Router weight over sticky cohorts is the sole activation primitive; flags + are in-pool emergency kill switches only. Cutover = weight 100% + CDN + purge; rollback = weight back + re-purge. ## 1. Problem statement -APS demand is fully integrated server-side — the edge runs the APS OpenRTB -auction, wins bids, and ships a typed renderer descriptor to the page — yet -APS creatives do not appear reliably for real users. Four serial fixes (the -`bid.meta` carrier, the decoupled prebid shim, the `hb_adid` fallback, and -now the baseline's PUC/collapsed-shell fix) each survived review; the pattern -is the finding: the pipeline has **multiple independent failure points, most -of which fail silently**, and the client cannot tell the server which fired. - +APS demand is fully integrated server-side, yet APS creatives do not appear +reliably. Four serial fixes (the `bid.meta` carrier, the decoupled shim, the +`hb_adid` fallback, the baseline PUC/collapsed-shell fix) each survived +review; the pattern is the finding: **multiple independent failure points, +most failing silently**, with no client→server signal about which fired. The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths, -duplicated ES5/TS logic, inverted layering, ~100 error-swallowing `catch` -blocks) is the same problem structurally. This design fixes APS delivery and -rebuilds TSJS so the next integration cannot reproduce this failure class. +duplicated ES5/TS logic, inverted layering, ~100 error-swallowing catches) +is the same problem structurally. ### Non-goals -- No change to the APS OpenRTB endpoint contract or Amazon-side configuration - (including its deliberate absence of `nurl`/`burl`, §G4d). +- No change to the APS OpenRTB endpoint contract (including its deliberate + absence of `nurl`/`burl`, §G4d). - No rewrite of the decoupled Prebid.js strategy. -- **No backward compatibility** (§0); replacement surfaces are in §7.4. +- No backward compatibility (§0); replacement surfaces in §7.4. ## 2. Why APS does not render — evidence Flows: (a) SSAT via `window.tsjs.bids`; (b) GAM + client `trustedServer` -Prebid adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction` -`tsjs.requestAds`. Only (d) — unused in production — renders an APS -descriptor without GAM. +adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction`. Only (d) renders +an APS descriptor without GAM. ### 2.1 Admission -| # | Failure | Where | -| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -| A1 | A configured `[auction].mediator` discards every direct-provider bid; winners come only from the mediator response. APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` | -| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; the drop is counted but invisible (A4). | `aps.rs:161`, `:334`, `:793` | -| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:675`, `:763-796`, `:859` | -| A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and the `ts-debug` allowlist exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | +| # | Failure | Where | +| --- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | +| A1 | A configured `[auction].mediator` discards every direct-provider bid; APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` | +| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; counted but invisible (A4). | `aps.rs:161`, `:334`, `:793` | +| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:675`, `:763-796`, `:859` | +| A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and `ts-debug` exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | ### 2.2 Identity -| # | Failure | Where | -| --- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| B1 | GAM caps key-value values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge equality check with no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1695` | -| B2 | Two id universes: SSAT keys on the APS bid id, the client adapter on Prebid's generated `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` | +| # | Failure | Where | +| --- | --------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| B1 | GAM caps key-values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge match, no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1695` | +| B2 | Two id universes: SSAT keys on the APS bid id; the client adapter on Prebid's `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` | ### 2.3 Render -| # | Failure | Where | -| --- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` is reachable only from flow (d). | `gpt/index.ts:923-1180`, `core/request.ts:59` | -| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1247`, `aps/render.ts:30`, `:415-437` | -| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` | -| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` | -| C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated; the served-through-APS-renderer log is reachable. | `gpt/index.ts:1729` | -| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` | -| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` | +| # | Failure | Where | +| --- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` reachable only from flow (d). | `gpt/index.ts:923-1180`, `core/request.ts:59` | +| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1247`, `aps/render.ts:30`, `:415-437` | +| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` | +| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` | +| C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated. | `gpt/index.ts:1729` | +| C6 | The renderer CSP can kill creatives after "ready". | `aps.rs:49` | +| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` | ### 2.4 Observability -Zero client→server reporting. Server telemetry marks `is_win=1` at auction -time; a bid that never painted is byte-identical to one that painted. +Zero client→server reporting; a bid that never painted is byte-identical +server-side to one that painted. ### 2.5 Failure → signal mapping (normative) -Each failure maps to a distinct observable **failure class** (not a claim to -distinguish unknowable root causes). The operator query for each row is the -§5.6 canonical view filtered by that row's event/reason or counter. So that -**diagnostic mode really can name every class from one page load** — the -review's objection that A1–A4 are server-side-only — the tester gate also -delivers a **`tsjs.boot.debug` envelope** in the initial HTML (selection -summary + drop summary for the initial auction) and the equivalent gated -`ext.trusted_server.debug` field on page-bids and `/auction` responses; -diagnostic mode mirrors these to the console: - -| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface | -| ------- | -------------------------------------------- | ------------------------------------- | ------------------------------- | -| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary | -| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary | -| A3 | — | `bid_drop{invalid_dimensions, w, h}` | `boot.debug` drop summary | -| A4 | — (fixed by §5.6 itself) | `bid_drop` rows on all paths | `boot.debug` / response `debug` | -| B1/B2 | `bridge_request{matched: false}` | join via trace | console warn | -| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn | -| C2 | `render_fail{renderer_document_no_load}` | renderer route counters | console warn | -| C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn | -| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn | -| C5 | — (fixed at baseline) | — | — | -| C6 | `runner_failed` + CSP buckets | CSP aggregate counters | console warn | -| C7 | renderer branch emits the full §5.1 sequence | join via trace | debug/warn | - -## 3. The GPT and baseline reality this design must respect - -1. Bootstrap-first hybrid: server-injected ES5 `gpt_bootstrap.js` wins the - sentinel race; the bundle's handoff/initial-load code is dead in +Two distinct gates, precisely separated: the **tester cookie** (explicitly +non-security, `tester_cookie.rs:3`) gates **debug content** — the +`tsjs.boot.debug` envelope and the page-bids/`/auction` +`ext.trusted_server.debug` fields, the same sensitivity class as the +existing tester-gated `ts-debug` HTML comment. The **diagnostic credential** +(§5.3) gates **telemetry volume** (unsampled mode). The tester cookie never +affects sampling; the credential never gates mere content. + +| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface | +| ------- | ----------------------------------------- | ------------------------------------- | ------------------------------- | +| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary | +| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary | +| A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary | +| A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` | +| B1/B2 | `bridge_request{matched:false}` | join via trace | console warn | +| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn | +| C2 | `render_fail{renderer_document_no_load}` | route counters (`ts_ops_counters`) | console warn | +| C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn | +| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn | +| C5 | — (fixed at baseline) | — | — | +| C6 | `runner_failed` + CSP buckets | `ts_csp_reports` | console warn | +| C7 | full §5.1 sequence from renderer branches | join via trace | debug/warn | + +## 3. The GPT and baseline reality + +1. Bootstrap-first hybrid; the bundle's handoff/initial-load code is dead in production. -2. The #922 merge loss: orphan recovery and `updateRender` are gone - (`0dc9b19a9`); bridge impressions double-count. PR #997 is the apparent - replacement. +2. #922 merge loss (`0dc9b19a9`); PR #997 is the apparent replacement. 3. TS refreshes never pass `changeCorrelator: false`. -4. `enableSingleRequest()` is called blind after publisher - `enableServices()`. +4. `enableSingleRequest()` called blind after publisher `enableServices()`. 5. Responsive-resolution ambiguity silently skips slots. -6. Three independent `pubads().refresh` wrappers coordinate via - window-global booleans. -7. **GPT has no request cancellation, no documented per-refresh identity, no - overlapping-completion order.** `slotRenderEnded` means creative code was - injected, not that resources loaded. `responseIdentifier` identifies the - ad **response** — usable for response dedup/drain, never initiation - attribution. -8. **With initial load disabled, `display()` creates no request** — the - subsequent `refresh()` does (`gpt/index.ts:1175`, - `ad_init.test.ts:1201-1263`). Cycle protocols must model physical - requests. -9. The bundle's `slotRenderEnded` registration is gated behind - `!ts.servicesEnabled` (`gpt/index.ts:1091`); G4a needs unconditional - early subscription. -10. **The baseline includes the fourth serial APS fix** (`248fe9558`): (a) - the renderer handshake moved to a **MessageChannel** on the PUC path — - port-transferred nonce message, descriptor over the port (no wildcard - broadcast on that path), exact-key replies, `ports.length` checks, a - one-shot `accepted` latch, port closing (`aps.rs:65-125`, - `aps/render.ts:415-437`) — but the reply still terminates inside the PUC - creative frame, so G4b's kernel-observability gap remains; (b) a - nonempty GAM render can be a **collapsed 1×1 shell**, remediated by - `resizeCollapsedCreativeFrame` (`gpt/index.ts:217`) — a guarded style - mutation of GAM-owned elements; (c) the dead renderer branch was - consolidated (C5); (d) a real-PUC-topology browser test now exists. -11. The bridge already keeps **consumed-id tombstones** for security - (`gpt/index.ts:1527`) — G2's registry rules must preserve that property - across navigations. -12. The current tester cookie is **explicitly not a security control** - (`tester_cookie.rs:3`) — it cannot gate unsampled telemetry (§5.3). +6. Three independent `pubads().refresh` wrappers. +7. **GPT has no cancellation, no per-refresh identity, no completion-order + guarantee**; `slotRenderEnded` = code injected, not resources loaded; + `responseIdentifier` identifies responses only. +8. **`display()` under disabled initial load creates no request — for any + caller.** GPT's behavior is caller-independent (`gpt/index.ts:1175`, + `ad_init.test.ts:1201-1263`); the G4a rules therefore apply to publisher + `display()` exactly as to TS `display()`. +9. `slotRenderEnded` registration gated behind `!ts.servicesEnabled` + (`gpt/index.ts:1091`); G4a needs unconditional early subscription. +10. Baseline fix `248fe9558`: MessageChannel APS-PUC handshake + (`aps.rs:65-125`, `aps/render.ts:415-437`; reply still terminates inside + the PUC frame), collapsed-shell resize (`gpt/index.ts:217`), C5 + consolidated, real-PUC browser test added. +11. The bridge keeps consumed-id tombstones for security + (`gpt/index.ts:1527`); G2 preserves that across navigations. +12. The tester cookie is not a security control (`tester_cookie.rs:3`). +13. **Fastly constructs application state per request** (`app.rs:146`) — no + cross-request in-memory state may be assumed on that adapter. ## 4. Design gates -### G1 — Trace identity and correlation - -The client-visible auction id is EC-derived (`publisher.rs:3237`) and never -ingested. Initial-HTML auction telemetry is emitted before page JS exists -(`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by -whoever acts first: - -- **Initial navigation (`nav_gen 0`):** the server mints `trace_id` (128-bit - CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction rows, - and injects it into `tsjs.boot` with the signed authorization (§5.3). -- **Cache-privacy invariant:** traces/authorizations are injected only into - responses that ran a per-request auction; such HTML is - `Cache-Control: private, no-store`, no validators. By construction + test. -- **SPA navigations:** `/_ts/page-bids` stays GET; the client mints - `trace_id`, sends it in `X-TSJS-Trace-Id`; the server records it and the - response echoes the trace + its authorization. -- **Direct `/auction` (closing the G4f gap):** the client sends the same - `X-TSJS-Trace-Id` header on the POST (`core/auction.ts:190` gains it); the - server validates, stamps that auction's rows, and echoes - `ext.trusted_server.trace = {trace_id, auth}` in the OpenRTB response — - covering pages whose initial HTML ran no auction. -- **Envelope:** every event carries `{nav_gen, refresh_gen, seq}` inside a - per-trace group `{trace_id, auth, events[]}`. `seq` is per-trace - monotonic. Gaps (loss) and duplicates (fetch/pagehide races) are expected; - §5.5 dedups. -- **Sampling is server-decided for every trace** and carried inside the - signed authorization (§5.3). Traces are navigation-scoped; **impression / - attempt counts are keyed by `(trace_id, nav_gen, refresh_gen, slot)`** - (success criterion 5 uses this key, not "per-impression traces"). +### G1 — Trace identity, sampling, and correlation + +- Client-visible auction id (EC-derived, `publisher.rs:3237`) is never + ingested. Initial-HTML telemetry precedes page JS, so correlation is + minted by whoever acts first: server for `nav_gen 0` (trace + signed + authorization in `tsjs.boot`); client afterwards, via `X-TSJS-Trace-Id` + on page-bids (GET) and on the `/auction` POST, echoed back with the + signed authorization (`ext.trusted_server.trace = {trace_id, auth, +auction_id}` on JSON/OpenRTB responses). +- **Sampling is a deterministic keyed function**, not a coin flip: + `mode = sampled iff HMAC(sampling_key, trace_id) < sample_rate` — so + concurrent requests presenting the same trace always derive the same + mode, and first issuance needs no shared state (Fastly is per-request, + §3.13). +- **Cross-tier join key (closing the row-multiplication gap):** the server + echoes its telemetry `auction_id` to the client (boot / response + extensions); the client stamps `auction_id` on every event of attempts + born from that auction. Server rows additionally carry `release_id`. + Canonical joins run on `(publisher_domain, trace_id, auction_id, slot, +refresh_gen)` at attempt grain; canary/control comparison uses the + server-side `release_id`. +- Cache-privacy invariant: traces/authorizations only in per-request + auction-bearing responses; such HTML is `private, no-store`. +- Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry + `{nav_gen, refresh_gen, seq, flow, auction_id?}`. **`flow` is a closed + field** `ssat | prebid | page_bids | direct | fallback` set by the + attempt owner — the per-flow funnels in the gates table depend on it. +- Traces are navigation-scoped; attempt counts key on + `(trace_id, nav_gen, refresh_gen, slot)`. ### G2 — Render identity -- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte - (`publisher.rs:3355`; PUC fetches `?uuid=`, `gpt/index.ts:1772`). Markup - bids: today's fallback chain. -- **Renderer-only bids:** `hb_adid` = server-minted token `^[a-z0-9]{12}$`, - CSPRNG, collision-retried within the minting auction; cross-auction - uniqueness probabilistic (36¹²; negligible) and harmless via scoping. -- **Registry:** keyed `(trace_id, nav_gen, refresh_gen)`; capacity 64 live - entries per navigation (`registry_full` on refusal); TTL 15 min; one-time - consumption. **Navigation disposal does not erase security state** - (review's tombstone finding): consumed, stale, and disposed ids move into - a bounded **RuntimeSession tombstone set** (cap 256, FIFO, entries retained - until their original TTL) — the bridge's TS-reserved check consults live - registry **and** tombstones, so a late prior-navigation request is still - suppressed and refused, never released to native Prebid. This preserves - the baseline's existing consumed-id tombstone behavior - (`gpt/index.ts:1527`). -- The client-Prebid path keeps Prebid's `adId`; both paths share the one - registry. Non-APS cache-path regression tests. - -### G3 — Runtime ABI under the IIFE build (exact-release) - -IIFE-per-bundle with inlined imports (`build-all.mjs:46`, `bundle.rs:23`) -means imports never share state across bundles (live defect: -`core/context.ts:11` vs `permutive/index.ts:102`). - -- Kernel ships only in `tsjs-core`; publishes - `tsjs._internal = { release_id, registry }` once; freezes after boot; - constructs and registers core services during boot. -- **Exact release matching:** every registration carries `release_id` - (plugins via the object-form API, §7.6, whose `release` field is a - build-generated constant); `registry.get(name)` succeeds only on equality; - mismatch quarantines with `abi_mismatch` / `bundle_partial` and a console - error. -- Stateful access only via the registry at call time; stateless helpers may - inline. Boundary enforcement is **two lint rules**: `import/no-restricted- -paths` for layering **and** `no-restricted-globals` forbidding - `window.googletag` / `window.pbjs` outside `adapters/` (import paths alone - cannot enforce §7.1). +- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte. Markup bids: + existing fallback chain. Renderer-only bids: server-minted token + `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, TTL 15 min, + one-time consumption. +- **Registry + tombstones, one capacity, no unexpired eviction:** the + bridge-reservation store holds live registrations and tombstones + (consumed / stale / navigation-disposed ids) in one bounded structure — + **capacity 320 for the union**; expired entries are pruned; **unexpired + entries are never evicted**; when the union is at capacity, **new + registration is refused** with `registry_full`. A late prior-navigation + bridge request therefore always meets suppression until its id's original + TTL passes (preserving `gpt/index.ts:1527`). Test: >320 registrations, + then a late request for the oldest unexpired id. +- Client-Prebid keeps Prebid's `adId`; one store serves both paths. + +### G3 — Runtime ABI (exact-release) + +- Kernel only in `tsjs-core`; publishes + `tsjs._internal = {release_id, registry}`; frozen after boot; core + services constructed at boot; plugins register via the object-form API + (§7.6) whose `release` is a build-generated constant; `registry.get` + succeeds only on `release_id` equality; mismatch quarantines + (`abi_mismatch`/`bundle_partial`) with a console error. +- Boundary enforcement: `import/no-restricted-paths` for layering plus + **`no-restricted-properties`/`no-restricted-syntax`** rules covering + `window`, `globalThis`, `self`, and local aliases for `googletag`/`pbjs` + access outside `adapters/` (`no-restricted-globals` cannot catch member + expressions). Adapters are the only access to **external ad-tech + globals**; the kernel and messaging module necessarily touch + `window.tsjs`, listeners, and `postMessage`. ### G4 — Render lifecycle -**G4a — Physical request-cycle protocol.** - -- **Intents, both classes, one causal queue.** Every observable initiation — - TS `display()`/`refresh()` and wrapped publisher entries — records an - intent in causal order, classified `ts | publisher`. A TS `display()` - issued while initial load is disabled is **known at call time to produce - no request** and is retired immediately as bookkeeping (it never enters - the matcher) — closing the review's misattribution case where a publisher - `refresh()` inside the 2 s window would have been consumed by a stale TS - intent. TS intents that _may_ produce no request only in hindsight - (`refresh()` on a never-displayed adopted slot) expire at 2 s with - `intent_no_request`; **if any publisher intent is recorded for the slot - while such a TS intent is pending, the next `slotRequested` is ambiguous - and the slot quarantines** — a zero-request TS intent can never silently - win FIFO matching. (Exact test in §9.) -- **Cycles:** opened only by `slotRequested`, matched to the head of the - causal intent queue; SRA batching yields one `slotRequested` per slot per - batch. A cycle closes on its `slotRenderEnded`; `responseIdentifier` - deduplicates responses during drain. -- **Serialization:** at most one outstanding TS cycle per slot; one queued - TS replacement (later intents coalesce). -- **Attribution:** a `slotRenderEnded` is attributable iff exactly one TS - cycle is outstanding and no publisher/untracked request overlaps. - Overlap → quarantine (`cycle_unattributable`, fail closed). -- **Drain/re-arm (no timeout re-arm).** Physical cycle and drain state live - in the RuntimeSession slot record; **unissued intents are - NavigationSession children** and are cancelled by navigation disposal. A - quarantined or stale slot re-arms only on: count-based drain (every - outstanding request/render pair matched), safe TS-owned slot destruction - and redefinition, or page end. **A timeout emits a diagnostic and never - restores attribution** — the 60 s bound from revision 5 is removed - because an old `slotRenderEnded` arriving after re-arm would be - indistinguishable from a new cycle. Late stale events are matched and - discarded (`stale_navigation`). -- CI exercises the protocol on the deterministic harness; a release-gating - **real-GAM overlap test** (publisher refresh racing a TS cycle; - initial-load-disabled formation) validates it against actual GPT. - -**G4b — Acknowledgement protocol (on the baseline port transport).** Since -`248fe9558` the frame pair speaks MessageChannel (parent-postMessage with -`ports.length === 0`, or transferred port with `ports.length === 1`; -exact-key replies; one-shot `accepted` latch; port closed after reply — -`aps.rs:65-125`, `aps/render.ts:415-437`). Adopted as the contract of record -within the frame pair. The kernel-observability gap remains (the PUC-flow -reply resolves inside the creative frame; callbacks fire on send, -`gpt/index.ts:1632-1760`). Contract — **three authenticated messages per -attempt**, each carrying the per-attempt 128-bit CSPRNG nonce from the -bridge response: - -1. `renderer_document_loaded` — posted to the top window after the document - validates the descriptor and nonce (this is §6.6's first stage, which - revision 5's two-message protocol omitted); -2. the port reply to its frame-pair peer (baseline behavior, unchanged); -3. `render_accepted` / `render_failed{reason}` — posted to the top window. - -The kernel validates, in order: source ownership (§6.8 walk), nonce, token, -`nav_gen`, `refresh_gen` — before any state transition or notification. The -one-shot latch + port close mean a re-render is a fresh document instance -with a fresh nonce. Pinned for SSAT, client-Prebid, and nested SafeFrame, -including stale/replayed acks and acks after navigation disposal. - -**G4c — Honest observations.** Inline-adm frames are sandboxed `srcdoc` -without `allow-same-origin` (`gpt/index.ts:510`) — opaque; geometry proves -nothing (the shell dimensions are assigned by our own code). Observations: -`gam_nonempty`, `gam_empty`, **`gam_collapsed`** (nonempty render whose -shell computes ≤ 1px — the baseline's discovery), `renderer_document_loaded`, -`runner_loaded`, `runner_failed`, `adm_document_loaded`. Every path -terminates at `render_accepted`; **no observation claims paint**; there is -no `render_confirmed`. The baseline's `resizeCollapsedCreativeFrame` -(`gpt/index.ts:217`) is adopted as a **sanctioned, guarded exception** to -the no-foreign-DOM-mutation rule (authenticated source frame only; wrapper -only when both dimensions ≤ 1px; anchor-ad `ins[data-anchor-status]` and -fixed/sticky guards) and emits `gam_collapsed` when it acts. - -**G4d — Win/billing notifications.** APS carries neither `nurl` nor `burl` -by design (`aps.rs:839`; the AAX envelope excludes them; the integration -guide documents no generic APS beacons) — APS billing lives in the Amazon -runner lifecycle, unchanged, and **APS is excluded from everything below**. - -For carrying paths (PBS and other OpenRTB providers): bind is per flow and -never selection or targeting (`ad_init.test.ts:1824` pins that): - -- GAM/PUC: an owned, slot-and-ad-id-matched bridge claim. -- Direct `/auction`: validated render start. **This requires server and - client work the current code lacks**: `/auction` response conversion must - preserve `nurl`/`burl` with server-side macro expansion - (`formats.rs:423` omits them today) and the client parser must carry and - https-validate them (`core/auction.ts:43` drops them today). -- Fallback: attributed `gam_empty`, immediately before fallback render. - -`nurl` at bind; `burl` at `render_accepted`; attempt-scoped idempotency key -`(trace_id, nav_gen, slot, refresh_gen, hb_adid)`; `sendBeacon`/no-cors -fetch; no retries. Post-acceptance terminal failure emits the dedicated -**`billing_outcome{billed_then_failed}`** event (§5.1) — it is not a -`render_fail` reason. - -**G4e — Fallback trigger.** Opt-in -(`[auction].client_render_fallback = "renderer"`); renders only after a -terminal `gam_empty` unambiguously attributed to a TS cycle; ownership does -not gate it; publisher-initiated or unattributable cycles never trigger it; -timeouts never render. - -**G4f — Direct `/auction` lifecycle.** The non-GPT path -(`core/request.ts:52`) gets: a `RenderAttempt` keyed -`(trace_id, nav_gen, refresh_gen, slot)` with `refresh_gen` incremented per -`requestAds` invocation touching the slot; **per-slot serialization with -latest-wins cancellation** — concurrent calls for the same slot cancel the -older attempt, and every DOM or beacon side effect re-checks its attempt -generation first, so a reversed-arrival response can never replace a newer -creative or start a second economic lifecycle (`request.ts:31` currently -races); G4b acknowledgement validation; G4d direct-flow binds; disposal on -navigation; exactly-once terminal state; the full §5.1 event sequence. -`tsjs.requestAds(options)` returns -`Promise` where -`RequestAdsResult = { traceId, slots: Array<{ slot, outcome: "rendered" | -"no_bid" | "failed" | "cancelled", reason? }> }`, settling when every slot -attempt reaches a terminal state. Reversed-response tests required. +**G4a — Physical request cycles.** + +- Intents recorded for both classes (`ts | publisher`) in one causal queue. + **Any `display()` issued while initial load is disabled is retired at + issuance regardless of caller** (GPT's no-request behavior is + caller-independent, §3.8) — a stale publisher `display()` intent can no + more poison a later TS `refresh()` match than the reverse. Hindsight + zero-request intents (`refresh()` on a never-displayed slot) expire at + 2 s with `intent_no_request`; while one is pending, any opposite-class + intent makes the next `slotRequested` ambiguous → quarantine. The + ambiguity rule is symmetric. +- Cycles open only on `slotRequested`, matched to the causal queue head; + SRA yields one per slot per batch; cycles close on `slotRenderEnded`; + `responseIdentifier` dedups during drain. +- One outstanding TS cycle per slot; one queued replacement (coalescing). +- Attribution requires exactly one outstanding TS cycle and no overlap; + otherwise quarantine (`cycle_unattributable`), fail closed. +- **No timeout re-arm.** Re-arm only on count-based drain, safe TS-owned + destroy/redefine, or page end. Unissued intents are NavigationSession + children; physical cycle/drain state is RuntimeSession. +- Deterministic-harness CI plus the release-gating real-GAM suite (scope + enumerated in the gates table: per-flow topologies, expected sequences, + browsers, fixtures, commands, artifacts, approvals — success criterion 1 + refers to that enumeration). + +**G4b — Acknowledgement, specified per render path.** Four normative +sequences; each names its nonce/token producer, transport into the owned +frame, authenticated acceptance observation, cancellation, and separate +document/runner deadlines (document 3 s, runner 10 s, adm 5 s): + +1. **APS-PUC** (baseline transport): bridge mints the per-attempt 128-bit + nonce; MessageChannel into the renderer document (`ports.length` + checks, exact-key replies, one-shot latch, port close); the document + posts authenticated `renderer_document_loaded` then + `render_accepted | render_failed{reason}` **to the top window**; the + kernel validates source ownership, nonce, token, `nav_gen`, + `refresh_gen` before transitions or notifications. +2. **Generic ADM/cache-PUC**: the bridge's display renderer creates the + sandboxed adm frame with an injected reporter snippet; the reporter + posts authenticated `adm_document_loaded{nonce}` to the top window on + document load; acceptance = that message (baseline merely appends an + iframe with no observation). +3. **Direct APS** (`renderApsCreative`): the kernel is the frame parent — + the baseline parent-postMessage handshake (`ports.length === 0` branch) + is already kernel-observed; same three messages, same validation. +4. **Direct ADM/cache**: as (2), with the kernel as parent. + +Cancellation for all four: navigation/supersession invalidates the nonce; +late acks are discarded with `stale_navigation`. + +**G4c — Honest observations; one terminal state.** Observations: +`gam_nonempty`, `gam_empty`, `gam_collapsed{action: resized | guarded, +reason}` (observation and remediation are separate — a guarded anchor/fixed +case is still observed), `renderer_document_loaded`, `runner_loaded`, +`runner_failed`, `adm_document_loaded`. **An attempt has exactly one +terminal state: `accepted | failed{reason} | no_bid | cancelled`.** +Post-acceptance runner failure is an observation plus the +`billing_outcome{billed_then_failed}` event — never a second terminal +transition. No observation claims paint. The baseline resize is a +sanctioned, guarded exception to the no-foreign-DOM-mutation rule. + +**G4d — Notifications.** APS carries neither `nurl` nor `burl` +(`aps.rs:839`); excluded entirely. For carrying paths: bind per flow — PUC: +owned, slot-and-ad-id-matched bridge claim; direct: validated render start +(server must preserve + macro-expand `nurl`/`burl` in `/auction` +responses — `formats.rs:423` omits them — and the client must parse and +https-validate them — `core/auction.ts:43` drops them); fallback: +attributed `gam_empty` immediately before render. `nurl` at bind, `burl` +at `accepted`. **Economic identity is the normalized pair +`(id_kind, id_value)`** (direct attempts without `hb_adid` use +`bid_id`), idempotency key +`(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`. **Every +dispatch emits `notification_sent{kind: nurl|burl, id_key_hash, result: +queued | failed}`** where `id_key_hash` is a 16-hex truncated HMAC of the +idempotency key — making the duplicate-`burl` invariant observable in +production (the gates table queries zero duplicates per key hash); external +billing reconciliation remains the authoritative backstop. No retries. + +**G4e — Fallback.** Opt-in; renders only on a terminal `gam_empty` +unambiguously attributed to a TS cycle; ownership does not gate; +publisher-initiated or unattributable never triggers; timeouts never +render. + +**G4f — Direct `/auction` lifecycle.** `RenderAttempt` keyed +`(trace_id, nav_gen, refresh_gen, slot)`; per-slot latest-wins with +cancellation; generation checks before every DOM/beacon effect +(`request.ts:31` races today); G4b sequence 3/4; G4d direct binds; +disposal on navigation; single terminal state; +`tsjs.requestAds(options): Promise` with +`RequestAdsResult = {traceId, slots: [{slot, outcome: "rendered" | +"no_bid" | "failed" | "cancelled", reason?}]}`. + +**G4g — Mid-attempt configuration.** An attempt **snapshots its +configuration at creation**. The in-pool emergency kill switch cancels +attempts that have not yet passed their commit point — defined as +`bridge_response_sent` (PUC flows) or first DOM insertion (direct/fallback +flows); attempts past commit run to their terminal state; dispatched +notifications are never recalled. ### G5 — Deployment contracts -- Config `format_version` exact-match; rollback by redeploy. -- Assets: hash-in-pathname; embedded only; unknown hash 410 `no-store`; - `Cache-Control: public, max-age=31536000, immutable` on exact matches - (`immutable` alone carries no lifetime). **Concatenation is materialized - and cached at application-state construction from the validated - configured module vector** — not per request (`bundle.rs:23` today), and - not a build-time-only set, since enabled vectors are runtime - configuration; an unlisted vector is a startup error. -- Internal route families (renderer, client-events, CSP reports): dispatch - before auth/EC/publisher/integration filters (Fastly today runs EC setup - and pre-route filters first, `app.rs:709`); all methods and version - prefixes reserved locally (405 + `Allow` + `no-store`; unknown version - 404 `no-store`; never the publisher fall-through in `adapter-spin -app.rs:804`); no body/cookie/authorization forwarding; origins compared - as normalized scheme+host+port. -- Ingest routes exist in all four adapters; Fastly has the real sink; the - others accept-count-drop (OQ5 drives their gates). +- Config `format_version`; release-time config (§0). +- **Assets pre-materialized at release publication:** because config ships + with the release, the validated module vectors are known when the release + is built — concatenated bytes + hashes are produced then and embedded; + serving is lookup-only on every adapter (Fastly's per-request state, + §3.13, makes construction-time caching meaningless there — the previous + revision's claim is corrected). The §7.10 server benchmark measures the + lookup path and guards against regression to per-request concatenation. + Unknown vector = release-build error; unknown hash = `410`, `no-store`; + exact match = `public, max-age=31536000, immutable`. +- **Internal route families — now four:** renderer, client-events, + CSP-report, **and `/_ts/trace-auth`** — dispatch before auth/EC/ + publisher/integration filters; all methods reserved locally (405 + + `Allow` + `no-store`; unknown versions 404 `no-store`; never publisher + fall-through); no body/cookie/authorization forwarding; normalized + scheme+host+port origin comparison; each family rate-limited (§5.4) and + covered by four-adapter parity tests. - §5.6 schemas deploy and validate before writers enable. ## 5. Observability -### 5.1 Wire payload and per-event field matrix +### 5.1 Wire payload and field matrix ``` -{ v: 1, traces: [ - { trace_id, auth, events: [ { nav_gen, refresh_gen, seq, t, ...fields } ] } -] } +{ v: 1, traces: [ { trace_id, auth, events: [ + { nav_gen, refresh_gen, seq, flow, auction_id?, t, ...fields } ] } ] } ``` -Event types and their fields (closed enums; a field absent from a row is -absent from the wire and NULL in storage): - -| `t` | fields | -| -------------------------- | ------------------------------------ | -| `bid_received` | slot, id_kind, source | -| `targeting_set` | slot, id_kind | -| `bridge_request` | slot, id_kind, matched | -| `bridge_response_sent` | slot, source | -| `render_attempt` | slot, source | -| `render_accepted` | slot, source | -| `render_fail` | slot, source, reason | -| `gam_nonempty` | slot | -| `gam_empty` | slot | -| `gam_collapsed` | slot | -| `renderer_document_loaded` | slot | -| `runner_loaded` | slot | -| `runner_failed` | slot, reason | -| `adm_document_loaded` | slot | -| `fallback_start` | slot | -| `billing_outcome` | slot, outcome (`billed_then_failed`) | -| `client_queue_overflow` | dropped (count) | - -`slot` is a configured slot id or `s`; `id_kind` ∈ -`cache_uuid | render_token | prebid_adid | bid_id | none`; `source` ∈ -`renderer | adm | pbs-cache | gam`. Reason enum: -`renderer_document_no_load`, `runner_no_load`, `runner_failed`, -`descriptor_invalid`, `invalid_dimensions`, `dimensions_out_of_range`, -`bridge_id_mismatch`, `cycle_unattributable`, `intent_no_request`, -`stale_navigation`, `bridge_claim_timeout`, `gam_empty`, -`no_render_source`, `slot_unresolved`, `gpt_absent`, `pbjs_absent`, -`bundle_partial`, `fallback_cancelled`, `abi_mismatch`, `registry_full`. -Queue overflow is its own event (`client_queue_overflow`), never a -`render_fail` — failure denominators stay clean. The payload carries **no -client timestamp**; the server stamps `received_at`, and ordering within a -trace is `seq`. - -### 5.2 Transport - -`fetch(..., {keepalive: true, credentials: "omit"})` primary; `pagehide` -fallback `navigator.sendBeacon(url, new Blob([json], {type: -"application/json"}))`. Flush every 5 s and on `visibilitychange`/ -`pagehide`. Client queue bound 256 events; overflow drops oldest and emits -`client_queue_overflow{dropped}`. +| `t` | fields (absent = absent on wire, NULL in storage) | +| -------------------------- | ------------------------------------------------- | +| `bid_received` | slot, id_kind, source | +| `targeting_set` | slot, id_kind | +| `bridge_request` | slot, id_kind, matched | +| `bridge_response_sent` | slot, source | +| `render_attempt` | slot, source | +| `render_accepted` | slot, source | +| `render_fail` | slot, reason, source? | +| `gam_nonempty` | slot | +| `gam_empty` | slot | +| `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? | +| `renderer_document_loaded` | slot | +| `runner_loaded` | slot | +| `runner_failed` | slot, reason | +| `adm_document_loaded` | slot | +| `fallback_start` | slot | +| `billing_outcome` | slot, outcome (`billed_then_failed`) | +| `notification_sent` | slot, kind (`nurl`\|`burl`), id_key_hash, result | +| `client_queue_overflow` | dropped (count) | +| `heartbeat` | probe_id, expected_seq | + +`source` is **nullable on `render_fail`**: absent for pre-source reasons +(`gpt_absent`, `pbjs_absent`, `slot_unresolved`, `intent_no_request`, +`abi_mismatch`, `registry_full`, `bundle_partial`); required for +source-specific reasons — the per-reason validity matrix is part of the +generated schema. Reason enum as revision 6 plus `currency_mismatch`. +**Heartbeats** are sent by identified probes under mode `probe` (§5.3): +excluded from every product metric by mode, never sampled out, and the +canonical freshness/loss query counts `expected_seq` gaps. + +### 5.2 Transport and overflow + +`fetch keepalive credentials:"omit"` primary; `sendBeacon(url, +new Blob([json], {type: "application/json"}))` on `pagehide`. Queue bound 256. **Overflow never enqueues into the full queue**: an out-of-band +saturating counter accumulates drops, and one coalesced +`client_queue_overflow{dropped}` is materialized into the **next flush**. ### 5.3 Signed trace authorization -Format `v1....` — **`auth` has its own ingest bound of -256 bytes** (it cannot fit the general 64-char string cap; every other -string keeps 64): - -- `kid`: `^[a-z0-9-]{1,16}$`; active + previous keys in the platform secret - store; keys ≥ 256-bit CSPRNG; **previous keys are retained at least - 24 hours** (≫ max token lifetime + skew); missing key at startup with the - beacon enabled = startup failure. -- `exp`: canonical decimal unix seconds (no sign, no leading zeros); ±60 s - skew; max future 15 min. -- `mode`: `sampled | unsampled | diagnostic`. **`unsampled` is the signed - discard decision** (the review's missing state): the client must not - enqueue or transmit events for an `unsampled` trace, and ingest rejects - any group whose token mode is `unsampled`; the decision is sticky for the - trace (renewals preserve mode). `diagnostic` is a distinct authenticated - mode — **not** gated by the tester cookie, which is explicitly - non-security (`tester_cookie.rs:3`); it requires a separate short-lived - **diagnostic credential** issued behind the existing operator/admin - authentication (`/_ts/admin` surface): HMAC-signed, bound to publisher - origin, expiry ≤ 60 min, revoked by key rotation, issuance - CSRF-protected; forgery/replay tests required. The tester cookie may - still gate cosmetic overlays; never telemetry volume. -- `sig`: base64url, unpadded, of HMAC-SHA-256 (43 chars) over the - domain-separated input - `"ts-trace-auth-v1" || u32be(len(origin)) || origin || -u32be(len(trace_id)) || trace_id || u32be(len(mode)) || mode || -u64be(exp)`, all strings UTF-8; constant-time comparison. -- **Renewal for long-lived pages:** before expiry the client calls - same-origin `GET /_ts/trace-auth` with `X-TSJS-Trace-Id`; the server - re-signs the **same trace id and mode** with a fresh `exp` (correlation is - the unchanged trace id). On renewal failure the client stops transmitting - and counts locally — silent rejection at ingest is thereby a bug, not a - policy. -- Ingest verifies per trace group; invalid/expired/unknown-kid → group - dropped-and-counted; other groups survive. - -### 5.4 Ingest contract - -- `POST /_ts/client-events`; `application/json` only; no - `Content-Encoding`; `204`, `no-store`; never echoes input. -- Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars except - `auth` ≤ 256 bytes; `trace_id ^[0-9a-f]{32}$`; integers `[0, 2³¹)`. -- Same-origin: `Sec-Fetch-Site: same-origin` when present, else normalized - `Origin` equality; absent both → drop-and-count. -- **Rate limiting via an adapter abstraction** (the review is right that a - cross-request in-memory token bucket cannot exist on Fastly, `app.rs:146`): - trait `ClientEventLimiter` with a declared per-adapter backing and - semantics — Fastly: the platform edge counter (`rate_limiter.rs:40`), - fixed 60 s window, limit 20/window (documented approximation of - 10 rpm + burst 20); Axum: real in-process token bucket (10 rpm, burst - 20), map ≤ 65,536 entries; Cloudflare/Spin: per-isolate/per-instance - best-effort with the same parameters. **At capacity, unseen identities - are rejected (drop-and-count); active buckets are never evicted by - churn.** Limiter unavailable/errored → drop early with `204`. Trusted - client address per adapter: Fastly platform client IP; Axum rightmost - `X-Forwarded-For` beyond required `trusted_proxy_hops` (absent → socket - peer only); Cloudflare `CF-Connecting-IP`; Spin platform address. - -### 5.5 Sink, canonical views, and monitoring - -- Stable event key `(publisher_domain, trace_id, seq)`. -- **One named canonical dedup pipe/view per table** (`ts_client_events_v`: - latest `received_at` per key; `ts_render_attempts_v`: attempt-grain - aggregation keyed `(trace_id, nav_gen, refresh_gen, slot)`). **Joins run - at attempt/slot grain against the views, never raw-to-raw** (a raw join on - `(publisher_domain, trace_id)` multiplies rows). Dashboards and alerts - may query only canonical views. -- Field naming matches the existing auction rows: **`publisher_domain`**. -- The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`) and - cannot see downstream rejection — **datasource-side monitoring is - mandatory**, driven by **sequence-tagged synthetic heartbeats** from a - probe client (accepted rows cannot reveal rejected rows): heartbeat gaps - measure rejection; heartbeat lag measures freshness. Alert owner: the - release owner's on-call. +`v1....`; `auth` ingest bound 256 bytes; kid +`^[a-z0-9-]{1,16}$`; keys ≥ 256-bit CSPRNG in the secret store, previous +keys retained ≥ 24 h; canonical decimal `exp`, ±60 s skew, ≤ 15 min future; +`sig` = unpadded base64url HMAC-SHA-256 over the domain-separated +length-prefixed input (revision 6's exact encoding); constant-time compare; +per-group rejection. + +- **Modes:** `sampled | unsampled | diagnostic | probe`. `unsampled` + transmits nothing and is rejected at ingest if carried. `probe` is + issued only to synthetic monitors (server-side issuance to the probe + runner) and marks heartbeat traffic. +- **Renewal preserves mode by verification, not trust:** `GET +/_ts/trace-auth` presents the **current signed authorization** in + `X-TSJS-Trace-Auth` (plus the trace header); the server verifies the + still-valid token and re-signs the **same trace_id and mode** with fresh + `exp`. The trace id itself carries no mode, and no adapter may rely on + cross-request state (§3.13) — the presented token is the state. Renewal + after expiry fails; the client stops transmitting and counts locally. +- **Diagnostic credential — complete protocol:** issuance `POST +/_ts/admin/diagnostic-credential` under the existing admin + authentication (CSRF: same-origin + custom header required), response + `{credential}` where credential = `d1....`, + absolute expiry ≤ 60 min, HMAC over the publisher origin + expiry, + **replayable short-lived bearer by design** (bounded by expiry and + origin binding; not one-time — stated, not implied). Transport to the + page: the operator opens the page with a `#tsdiag=` fragment + (never sent to any server in a URL); the client stores it in + `sessionStorage` and presents it in `X-TSJS-Diag` on trace-auth, + page-bids, and `/auction` requests. The server, seeing a valid + credential, issues/renews the trace authorization with + `mode = diagnostic` and **`exp = min(now + 15 min, +credential expiry)`** — a trace authorization never outlives the + credential. Initial HTML cannot see the fragment, so `nav_gen 0` starts + `sampled|unsampled` and the client immediately upgrades via trace-auth. + Validation is stateless HMAC — all four adapters support it. Forgery, + replay-past-expiry, and wrong-origin tests required. The tester cookie + remains content-only (§2.5). +- **Lazy cached initialization applies to every secret-backed component** + (trace-auth keys, diagnostic keys, sampling key, sinks): first-use + resolution with a cached result on request-bound platforms; resolution + failure with the feature enabled → the feature's startup/first-use error + path, never silent. + +### 5.4 Ingest and rate limiting + +As revision 6 (limits, same-origin, fail-closed drops), with the limiter +contract completed: key namespace per route family; portable maps hold +≤ 65,536 (Axum) / 4,096 (Cloudflare, Spin per-instance) entries with +10-minute entry TTL, cleanup on access plus periodic sweep — **capacity +pressure rejects unseen identities but expired entries are always +reclaimable, so saturation is bounded, not permanent**; missing client +address → a shared `unknown` bucket at 1 request/min; Fastly uses the +platform 60 s window counter at limit 20 with documented overshoot ≤ 2× +under concurrent bursts (`rate_limiter.rs:40` is read-then-increment); +Axum XFF selection = rightmost entry after skipping exactly +`trusted_proxy_hops`. `/_ts/trace-auth` and `/_ts/csp-reports/` +get their own buckets (10/min, burst 20 intent). + +### 5.5 Sink, canonical views, monitoring + +Stable event key `(publisher_domain, trace_id, seq)`; canonical views +`ts_client_events_v` (dedup) and `ts_render_attempts_v` (attempt grain, +joined on the G1 key including `auction_id`); dashboards/alerts query views +only. Datasource-side monitoring via `heartbeat` events from identified +probes (mode `probe`); freshness = heartbeat lag ≤ 5 min, loss = +`expected_seq` gaps < 0.1%; alert owner: release owner's on-call. ### 5.6 Physical schemas (deployed before writers) -- **`ts_client_events`**: `received_at DateTime64, publisher_domain -LowCardinality(String), release_id String, trace_id FixedString(32), -mode Enum(sampled|diagnostic), nav_gen UInt32, refresh_gen UInt32, -seq UInt32, event Enum(§5.1), slot Nullable(String), id_kind -Nullable(Enum), matched Nullable(UInt8), source Nullable(Enum), reason -Nullable(Enum), outcome Nullable(Enum), dropped Nullable(UInt32)`. - Sorting key `(publisher_domain, received_at, trace_id, seq)`; TTL - 30 days; own ingest token; sink batch cap 512 rows; startup validation of - dataset + token when enabled ("startup" on request-bound platforms such - as Cloudflare means first-request lazy initialization with a cached - result); sink-unavailable at runtime → accept-count-drop. -- **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`): - add nullable `trace_id`, `mode`; add two bounded row types — **`bid_drop`** - `{provider, slot Nullable, reason Enum, width Nullable(UInt16), height -Nullable(UInt16), count UInt32}` (nullable slot/dimensions for - response-level failures; cap 32 rows/auction + overflow row) and - **`selection_summary`** per slot - `{slot, winner_source Enum(mediator|direct|none), winner_provider, -candidates_direct UInt16, candidates_mediator UInt16, dedup_hits, -currency_rejected, provenance_invalid, mediator_superseded}` (cap 8 - rows/auction + overflow) — §6.1's selection report now has a physical - home. -- **Settings schema (complete):** `[telemetry.client_events]` `enabled`, - `sample_rate` (0–1), `dataset`, `token_secret`; `[telemetry.trace_auth]` - `secret_store`, `active_kid`, `previous_kids = []`; the diagnostic - credential secret alongside. `RuntimeServices` (`platform/types.rs:158`) - gains the client-events sink handle next to the auction sink. -- APS parsing returns structured drop observations - `{reason, slot, width?, height?}` (`aps.rs:722` today loses slot and - values); >8192 → `dimensions_out_of_range`, dimensions omitted. +- **`ts_client_events`**: revision 6 columns plus `flow Enum`, + `auction_id Nullable(FixedString(36))`, `action Nullable(Enum)`, + `kind Nullable(Enum)`, `id_key_hash Nullable(FixedString(16))`, + `result Nullable(Enum)`, `probe_id Nullable(String)`, + `expected_seq Nullable(UInt32)`; `mode Enum(sampled|diagnostic|probe)`. +- **Auction rows**: nullable `trace_id`, `mode`, plus **`release_id`** + (the server-side cohort discriminator). Two added row types with full + physical definitions: + - `bid_drop {provider LowCardinality(String), slot Nullable(String), +reason Enum(AuctionDropReason), width Nullable(UInt16), height +Nullable(UInt16), count UInt32}` — cap 32 rows/auction **plus** one + overflow row (`reason = overflow`, `count` = dropped-row count; the + overflow row is not counted against the cap); + - `selection_summary {slot String, winner_source +Enum(mediator|direct|none), winner_provider Nullable(String) — NULL +exactly when winner_source = none, candidates_direct UInt16, +candidates_mediator UInt16, dedup_hits UInt16, currency_rejected +UInt16, provenance_invalid UInt16, mediator_superseded UInt16}` — cap + 8 rows/auction plus one **auction-level totals row** (slot = + `_totals`) that always survives truncation, so gate denominators never + depend on per-slot rows. Counters are saturating UInt with + `0xFFFF`/`0xFFFFFFFF` as the saturation sentinel. + - **`AuctionDropReason` (closed, server-side):** `script_rendering_ +disabled, invalid_dimensions, dimensions_out_of_range, +missing_render_source, invalid_creative_url, unsupported_tagtype, +render_payload_too_large, unexpected_response_shape, +currency_mismatch, floor_rejected, provenance_invalid, +duplicate_demand, overflow`. +- **`ts_csp_reports`** (aggregate only): `{received_at, release_id, +policy_id, cohort, directive_bucket Enum, source_bucket Enum, count}`; + 30-day TTL. **CSP ingest contract:** pre-buffer body ≤ 8 KiB, ≤ 10 + reports/request, strings ≤ 256, nesting ≤ 4, no `Content-Encoding`, own + limiter bucket, fire-and-forget dispatch covered by probe heartbeats. +- **`ts_ops_counters`**: `{received_at, release_id, counter Enum, value +UInt64}` — the physical home for renderer-route counters (requests, + unknown-version, auth-blocked) and limiter/abuse counters. +- **Settings, constructible:** `[telemetry.client_events]` + `collection_enabled`, `sink_enabled` (collection without a sink = + accept-count-drop by configuration, not accident), `sample_rate`, + `api_host`, `dataset`, `token_secret`, `secret_store`, + `max_body_bytes`; `[telemetry.trace_auth]` `secret_store`, + `active_kid`, `previous_kids`, `sampling_key_secret`; + `[telemetry.diagnostic]` `secret_store`, `active_kid`. + `RuntimeServices` gains the client-events sink handle. +- APS parsing returns structured drop observations (slot + dimensions); + > 8192 → `dimensions_out_of_range`, dimensions omitted. ### 5.7 Modes and SLIs -- **Production (sink-backed only):** server-decided sampling - (`sample_rate`, default 0.10; `sampled` vs `unsampled` signed per trace). - Separated SLIs: **pipeline availability** (heartbeat freshness ≤ 5 min, - heartbeat loss < 0.1%; fails during sink outages, alarmed); **failure - detection** (a failure mode affecting ≥ 1% of sampled render attempts - visible within one hour, evaluated at ≥ 10,000 sampled attempts/hour). -- **Diagnostic:** credential-gated (§5.3), unsampled, full stream, console - mirroring, plus the `boot.debug` / response-`debug` envelopes (§2.5) — one - page load names the failing class for every §2 row. +Production (sink-backed): deterministic keyed sampling (default 0.10). +SLIs: pipeline availability (heartbeat freshness/loss); failure detection +(≥ 1% of sampled render attempts visible within one hour at ≥ 10,000 +sampled attempts/hour). Diagnostic: credential-gated, unsampled, full +stream + console mirroring + debug envelopes (§2.5). ### 5.8 Server-side drop surfacing -Bounded structured summary whenever any bid is dropped; `bid_drop` + -`selection_summary` rows; `ts-debug` comment carries the drop summary; -page-bids and `/auction` carry the gated structured `debug` field. Startup -warnings: APS + `allow_script_creatives = false`; mediator + direct -providers without an explicit `winner_selection` (§6.1 hard error). +As revision 6, with `selection_summary`/`bid_drop` physicalized above. ## 6. APS delivery fixes -### 6.1 Mediation: complete, arrival-independent algorithm - -Current code cannot merge: no forwarded candidate id; lossy -last-write-wins `(provider, slot, bidder)` restoration -(`adserver_mock.rs:95`); arrival-order ties (`orchestrator.rs:827`); Prebid -assumes USD (`prebid.rs:2433`) and APS stamps USD (`aps.rs:475`) with no -configured currency. Replacement, identical in the synchronous and split -dispatch/collect paths via one shared helper: - -1. **Currency.** New required field `[auction].currency` (ISO 4217). Every - provider parse validates its response currency against it (absent - declaration where the provider contract implies one — APS's USD — is - validated as that implied value); mismatch → `bid_drop{currency_mismatch}`. - No conversion. -2. **Candidates.** Every admitted bid — direct **and mediator-native** — - becomes a candidate. Identity is two-part: `source_candidate_id` = - the **intrinsic stable key** `(provider_name, upstream_bid_id)` (for - mediator-native bids: the mediator's provider name and its bid id), and - `candidate_id` = a server-minted opaque wire id (`c` + 11-char CSPRNG) - used **only** for the mediator echo — never for ordering, so response - arrival order cannot influence selection. -3. **Mediator exchange.** Forwarded candidates carry `candidate_id` in the - named wire extension **`ext.trusted_server.candidate_id`** (contract for - every mediator implementation, `adserver_mock` included); the mediator - echoes it on derived bids. Echoed id resolves → the bid is the forwarded - candidate with provenance `mediator`; **authoritative fields:** price - and deal fields come from the mediator (repricing is its job); - render-source fields (renderer, adm, cache coordinates, notification - URLs) come from the stored candidate — a mediator that returns its own - `adm` for an echoed candidate is treated as mediator-native demand - instead. Unresolvable echoed id → the slot **fails closed for merging** - (`mediation_provenance_invalid`; mediator-native bids for the slot still - compete; the claim is discarded and counted). -4. **Floors** filter both populations. **Dedup:** an echoed candidate - removes its direct twin. -5. **Selection order (total, intrinsic):** decoded CPM desc → provenance - rank (mediator first) → `source_candidate_id` asc. Winner fields are - read from the stored candidate per rule 3. -6. **Strategy.** `[auction].winner_selection` is **required** whenever a - mediator and direct providers coexist (startup error if absent): - `mediator_only` or `merge_highest_cpm`. **Timeout behavior is - strategy-specific:** under `merge_highest_cpm`, mediator timeout → - direct-only selection, reported; under `mediator_only`, mediator timeout - → **no winners** (direct bids stay signal-only) unless - `mediator_timeout_fallback = "direct"` is explicitly configured. -7. **Reporting:** the `selection_summary` row (§5.6) per slot. - -Deal priority stays out of scope (the `Bid` model carries no deal identity; -recorded follow-up). - -### 6.2 Dimensions - -Exact size membership stays (`aps.rs:675`). Fix is visibility -(`bid_drop{invalid_dimensions, w, h}`) plus documentation: request the -sizes you accept. - -### 6.3 Script creatives - -Default stays `false`; consequence loud (§5.8); enablement documented. - -### 6.4 Render identity - -As G2 (including tombstones). - -### 6.5 Fallback - -As G4e/G4a; awaitable renderer first; attribution-gated; timeouts never -render. +### 6.1 Mediation — total order, closed contradictions + +1. **Currency.** Required `[auction].currency` (ISO 4217). Providers + validate response currency at parse; providers with a contract-implied + currency validate that implication — **APS enabled with a non-USD + configured currency is a startup error** (`aps.rs:475` stamps USD), not + a silent all-drop. Prebid's parse must validate rather than assume USD + (`prebid.rs:2433`). Mismatch → `bid_drop{currency_mismatch}`. +2. **Candidate identity, total by construction.** `source_candidate_id` = + `(provider_name, upstream_bid_id)` where the upstream id is **required + bounded (≤ 64 chars) and unique per provider response at admission**; + a bid missing an id, or duplicating one, receives a deterministic + **intrinsic fingerprint**: `fp = hex(HMAC(auction_id, provider || slot +|| price_micros || render_source_digest))` — identical fingerprints are + the same demand and dedup to one candidate. `candidate_id` (wire echo) + is CSPRNG with in-auction collision retry and is **never** an ordering + key. +3. **Mediator exchange.** Forwarded candidates carry + `ext.trusted_server.candidate_id`; the mediator echoes it. Resolution + rules, stated exhaustively: an echoed id that resolves → the forwarded + candidate, provenance `mediator`; **price is authoritative from the + mediator; every render-source and notification field comes from the + stored candidate; deal fields are out of scope entirely** (no deal + identity exists in the model — revision 6's "deal fields from mediator" + is deleted). A mediator bid whose **any** render-source field differs + from the stored candidate is reclassified mediator-native. An echoed id + that does **not** resolve → that bid is discarded and counted + (`provenance_invalid`); **mediator-native bids and direct candidates + for the slot all remain eligible** — "fails closed" applies to the + invalid claim, not the slot. +4. Floors filter both populations; echoed candidates remove their direct + twins. +5. **Selection order (total):** decoded CPM desc → provenance rank + (mediator first) → `source_candidate_id` asc (fingerprints compare as + their hex strings). Arrival order can never matter. +6. **Strategy** required when mediator + direct providers coexist: + `mediator_only` (timeout → no winners unless + `mediator_timeout_fallback = "direct"`) or `merge_highest_cpm` + (timeout → direct-only, reported). +7. Reporting: `selection_summary` rows + `_totals` (§5.6). + +### 6.2–6.5 + +As revision 6: exact dimensions with structured drops; script creatives +default-off but loud — **DR-2's output is a deployment decision** (enable +with explicit sandbox/security approval, or accept a quantified maximum +excluded-demand share and gate Phase 3 on it), not a documentation +priority; G2 identity; G4e fallback. ### 6.6 Renderer endpoint -- Route registers unconditionally in every adapter (provider stays - config-gated); startup validation fails if an auth handler pattern covers - it; §G5 isolation rules apply. -- Path `/integrations/aps/renderer/v1`, embedded, served - `Cache-Control: public, max-age=31536000, immutable`; canary versions - are `no-store` (or bounded below the cohort lifetime); a **checked-in - header manifest per renderer version** freezes headers (CSP included) - with the bytes — a version's headers never change after publication, - resolving the immutable-caching/CSP conflict. Unknown versions → 404 - `no-store`. -- Three-message acknowledgement per G4b (`renderer_document_loaded` is the - first authenticated envelope). -- Server route counters are aggregate (the nonce rides the URL fragment). -- **CSP rollout:** discovery on the currently enforced policy with - reporting; tightening via report-only; relaxation via a small enforced - cohort on a short-lived canary version, gated on runner acceptance, CSP - violation rate, and render-failure rate, with a kill switch — and **CSP - reports are advisory**: for opaque-origin reports the body-supplied - document URL and policy version are forgeable, so **policy identity is - encoded in a server-selected report path** - (`POST /_ts/csp-reports/`, ids server-generated per version), - reports are bucketed into closed effective-directive buckets and - `https-host (allowlisted) | data | blob | inline | eval | other` source - buckets with global and per-cohort caps, unused fields discarded before - logging, and CSP data is **never a sole automatic rollback signal**. - Physical storage: aggregate counters only. Browser capture on Chromium, - Firefox, and WebKit (CI is Chromium-only today, - `playwright.config.ts:16`), both report media types - (`application/csp-report`, `application/reports+json`) with separate - validators. +As revision 6 (unconditional route, versioned immutable document with a +checked-in per-version header manifest, three-message ack, aggregate route +counters now physically in `ts_ops_counters`, CSP rollout with +server-selected `policy_id` report paths and closed buckets — ingest +bounds per §5.6). ### 6.7 One descriptor schema -Wire truth is the tagged `BidRenderer` envelope (`types.rs:188-211`). A -wire-schema crate/xtask (no `core → js` cycle; core already depends on the -js crate, `Cargo.toml:45`) generates the JSON-Schema artifact, the TS -structural parser, the ES5 inline validator fragment, and shared fixtures — -checked in, staleness-gated. Semantic checks (URL/origin policy, canonical -base64, bounds, exact one-bid AAX projection, cross-field equality) stay -handwritten. Outer-descriptor tolerance only; AAX projection exact. Shared -positive + adversarial corpus runs through all three validators in CI. +As revision 6 (generated JSON-Schema + TS parser + ES5 inline fragment + +fixtures; semantic validators handwritten; outer tolerance only; the +per-reason `source` validity matrix of §5.1 joins the generated artifacts). ### 6.8 Bridge hardening -Order (normative; preserves the baseline defense that suppresses -propagation before source validation, `gpt/index.ts:1584-1637`): parse → -identify TS-reserved id (live registry **or tombstone**, G2) → -`stopImmediatePropagation()` → validate source ownership via the bounded -walk (known slot-root `WindowProxy` map; sender's parent chain to depth 5; -never scanning the frame tree) → validate nonce/token/`nav_gen`/ -`refresh_gen` → respond or refuse (`bridge_id_mismatch`). Non-TS ids are -untouched. The stolen-token browser test asserts neither TS nor native -Prebid responds; listener-order has a real-browser assertion. Renderer -branches emit the full §5.1 sequence; notifications only on carrying paths. +As revision 6: parse → identify TS-reserved (live **or tombstoned**, G2) → +`stopImmediatePropagation` → validate source (bounded walk) → +nonce/token/`nav_gen`/`refresh_gen` → respond or refuse. Non-TS ids +untouched. Stolen-token test proves neither TS nor native Prebid responds. ## 7. TSJS target architecture ### 7.1 Layering -``` -kernel/ boot, config, queue, event bus, log, beacon, sessions -adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access -services/ slots (registry+handoff), auction client, render engine, consent -integrations/ gpt, prebid, aps, creative, datadome, … -``` +As revision 6, with G3's corrected lint mechanics and the adapter-scope +clarification (external ad-tech globals only). + +### 7.2 Adapters / 7.3 Slot registry + +As revision 6 (registry holds the G4a causal queue; cycle/drain state +RuntimeSession; unissued intents NavigationSession). + +### 7.4 Final global surface + +Revision 6's table **plus** the row the review found missing: + +| Legacy surface | Final shape | +| -------------- | ----------------------------------------------------- | +| (new, public) | `tsjs.definePlugin({id, release, install, dispose?})` | + +Bootstrap: field-wise idempotent init (`window.tsjs ||= {}; tsjs.que ||= +[]; tsjs.boot ||= {}`; the ad-slot script's `window.tsjs = {}` at +`publisher.rs:3665` is fixed); **transactional ownership**: states +`unclaimed → installing → kernel | fallback`. The kernel installs wrappers +**inert** and flips them live at a single commit point; on a throw before +commit it unwinds its registered disposers (the §7.6 machinery applied to +kernel boot itself) and marks `failed`; the 10 s watchdog treats a stuck +`installing` as failed; **fallback claims ownership only from +`unclaimed | failed`** — never beside a committed kernel. A bundle +arriving after fallback committed defers for the page (`bundle_partial`). +Tests: throw injected after each boot checkpoint. + +### 7.5 Messaging / 7.6 Plugins and sessions / 7.7 Bootstrap / 7.8 GPT / 7.9 Decomposition + +As revision 6 (plugin API object form with `release`; transactional +install; sessions; generated no-bundle fallback; unconditional GPT +subscriptions; decomposition table). + +### 7.10 Performance (fully reproducible) -Enforced by the two G3 lint rules. Dissolves the audited inversions -(`core/auction.ts`/`core/request.ts` → `integrations/aps/render`; -`gpt`/`prebid` → `aps`; `prebid` owning the GPT refresh wrapper). - -### 7.2 Adapters - -`present | pending | timed_out` per external global; `timed_out` -non-terminal; queued operations carry their own timeouts and expire with -disposition reasons. - -### 7.3 Slot registry service - -Kernel-owned; `WeakMap` + div-id index; -ownership, adoption, handoff claims, responsive resolution, the G4a causal -intent queue + cycle/drain state (RuntimeSession) and unissued intents -(NavigationSession), targeting history. No expandos -(`__tsRenderGeneration`/`__tsRenderBid` deleted). - -### 7.4 Final global surface (hard cutover) - -| Legacy surface (removed at cutover) | Final shape | -| --------------------------------------- | --------------------------------------------------------------------- | -| `window.tsjs.que` | `window.tsjs.que` — unchanged | -| `globalThis.tscreative` | `tsjs.creative.*` | -| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | -| `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) | -| `window.__tsjs_*` flags, config globals | `tsjs.boot.*` | -| install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) | -| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` | -| `tsjs._internal` | kernel registry (G3), frozen after boot | - -**Bootstrap correctness (closing the review's two holes):** every -server-injected initializer creates the container **idempotently and -field-wise** — `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}` — -never only-when-absent (today the first ad-slot script does -`window.tsjs = {}`, `publisher.rs:3665`, which would clobber or starve -later `boot` writes). Kernel boot claims an **atomic owner sentinel**; it -consumes `boot`, deep-freezes the retained copy, and deletes one-shot -secrets. The generated no-bundle fallback (§7.7) activates on bundle -`error` **or** a bounded hang watchdog (10 s without kernel boot); if the -fallback has activated and the bundle later arrives, the bundle **defers -for the rest of the page** (logs + `bundle_partial` disposition) — queue -ownership never changes hands mid-page. - -### 7.5 Messaging module - -All `postMessage` through one module: versioned envelopes, name constants, -G4b nonces, §6.8 validation. Minimal module lands in Phase 1; full call-site -migration in Phase 4. - -### 7.6 Plugin lifecycle — transactional — and sessions - -`tsjs.definePlugin({id, release, install, dispose?})` — object form; the -`release` field is the build-generated `release_id` constant (G3 needs it; -revision 5's positional API omitted it). `install(ctx): void | -Promise`: - -- `ctx.signal`; synchronous `ctx.onDispose(fn)`; reverse-order unwind on - throw/reject/abort; per-disposer isolation; disposer registered after - disposal → invoked immediately; pending late registrations capacity 16, - bound 10 s → `bundle_partial`; release mismatch quarantines before - `install`. -- Sessions: `RuntimeSession` (page-lifetime: bridge listener + tombstones, - history hook, pbjs subscriptions, adapters, beacon queue, physical slot - cycle/drain state); `NavigationSession` (trace + authorization + renewal - timer, render attempts, slot aliases, unissued intents, targeting - history); `RenderAttempt` (per G4a cycle / G4f attempt). Enumerable - disposal inventories; navigation disposes NavigationSession children - only. -- No empty `catch`; auction fetch gains timeout + `AbortController`. -- **Console logging retained**: every issue-surfacing condition keeps or - gains a `log.warn` with the beacon's reason code; `debug`-level - delivery/security failures promoted to `warn`. - -### 7.7 Bootstrap - -`gpt_bootstrap.js` shrinks to a queue-and-flags stub; the bundle replays -recorded calls on install (browser specs cover replay timing); the -no-bundle fallback is **generated from the same TypeScript source**, with -the §7.4 activation/arbitration rules. - -### 7.8 GPT correctness carried with the restructure - -Unconditional early `slotRequested`/`slotRenderEnded` subscription -(replacing the `!servicesEnabled` gate, `gpt/index.ts:1091`; idempotent -recording); restore #922/#997 attribution and orphan recovery; -`changeCorrelator: false` on TS refreshes (configurable); -`enableSingleRequest()` only when services are not already enabled; -ambiguous responsive resolution emits `render_fail{slot_unresolved}`. - -### 7.9 Decomposition targets - -| Today | Target | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | -| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics | -| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | -| `core/trace.ts` (model + UI) | `services/trace` + `integrations/trace_overlay` | -| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal | - -### 7.10 Performance (reproducible) - -- **Dedicated workflow** on a fixed runner (`runs-on: ubuntu-24.04` - explicitly — browser CI is `ubuntu-latest` today, - `integration-tests.yml:155` — inside a pinned container image digest); - browser = the lockfile-resolved `@playwright/test` build with its browser - revision recorded in the baseline artifact (the manifest is a caret - range today, `browser/package.json:10` — the lockfile + recorded - revision are authoritative); compressors pinned by version in the - container (`gzip -9 -n` for determinism, `brotli -q 11`). -- Bundle budgets: raw/gzip/Brotli for three vectors (minimal, reference, - maximal) vs checked-in baselines (`perf/baselines/*.json`, updates are - reviewed diffs recording image/browser/tool versions); +5% bytes. -- Browser timing: 5 warm-ups discarded, 50 samples, p90 ≤ baseline × 1.10. -- **Server benchmark harness (complete):** workload = concatenation + - hash of the reference vector; 100 warm-up iterations, 1,000 measured; - statistic = median and p90; one-sided gates ≤ baseline × 1.10; variance - policy: 3 consecutive runs must agree within 5% or the result is - inconclusive (rerun, never pass). +Revision 6's pinned workflow, plus the missing definitions: **module +vectors enumerated** — minimal = `[core]`; reference = `[core, creative, +gpt, prebid, datadome]`; maximal = all 13 discovered modules. **Browser +timing marks**: `performance.mark("tsjs:bids-script")` emitted by the +injected bids script and `performance.mark("tsjs:first-display")` emitted +by the adapter wrapper at the first `display()`/`refresh()` dispatch; +metric = duration between marks on the reference fixture page (the +integration-tests reference page), warm HTTP cache, all resources served +locally (no external network); p90 = nearest-rank over 50 samples; +inconclusive (3-run agreement worse than 5%) → one rerun, then fail. +**Maximal-vector peak JS heap ≤ baseline × 1.10.** Server benchmark: the +G5 lookup path (not concatenation), 100 warm-ups, 1,000 iterations, median +and p90, one-sided ≤ baseline × 1.10. ### 7.11 Toolchain -TypeScript floor to the resolved 5.9 line; strictness flags on; dev +TypeScript floor to the resolved 5.9 line. **Release-gating flags, +enumerated:** `strict`, `noUncheckedIndexedAccess`, +`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, +`noImplicitOverride`, `useUnknownInCatchVariables`; CI command: +`npx tsc -p crates/trusted-server-js/lib/tsconfig.json --noEmit`. Dev toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from casual bumps; monthly review. -## 8. Rollout: phases, decision records, and executable gates - -Phases are build milestones inside the §0 single-release model (dark pool → -probe gates → router-weight canary of coherent requests → full weight). - -**Phase 0 decision records** (promoted from open questions; each has an -owner, evidence, a deadline, and an explicit go/no-go): DR-1 mediator -presence in the affected deployment (OQ1 — decides whether §6.1 gates -Phase 3 entry); DR-2 script-creative share (OQ2 — decides the §6.3 -guidance priority); DR-3 #922 vs #997 (OQ4 — decides the Phase 3 work -item); DR-4 mediator candidate-id echo owner and timeline (OQ7 — -`merge_highest_cpm` is config-blocked until delivered); DR-5 non-Fastly -sink decision (OQ5 — splits Phase 2's gates below). - -**Gates are a checked-in table** (`docs/superpowers/specs/rollout-gates.md`, -created in Phase 0) with columns: query/test command, assignment key, -expected positive count, denominator, sample floor, threshold, window, -owner, hold/rollback action. The real-GAM suite's row includes its workflow -name, fixture account and credentials owner, invocation command, artifact -location, retry policy, and required approval evidence. Prose below is the -summary; the table is normative. - -- **Phase 0 — Identity, schemas, toolchain, decisions.** Path-hashed - assets + 410 semantics + construction-time concatenation cache; - `format_version`; §5.6 schemas deployed writer-off; toolchain floors; - dead expando writes deleted; §5.8 drop surfacing; the five decision - records; the gates table itself. -- **Phase 1 — Kernel, sessions, minimal messaging, cycle registry.** G3 - registry; sessions; install manifest; minimal messaging; G4a intent/cycle - records; unconditional GPT subscriptions. -- **Phase 2 — Trace + beacon.** G1 issuance on all three paths (boot, - page-bids, `/auction` extension); §5.3 authorization incl. renewal and - the diagnostic credential; four-adapter ingest; `ts_client_events` - writers on. Gates split per DR-5: **HTTP parity** (all adapters: - routing, limits, 204s, method reservations) vs **persistence** - (sink-backed only: acceptance, dedup-exactly-once, heartbeat freshness). -- **Phase 3 — APS delivery.** Schema crate + corpus; §6.1 with required - `winner_selection` and `[auction].currency`; render token + tombstones; - renderer route + three-message ack + CSP report route; §6.8; G4a–G4f - incl. direct-flow `nurl`/`burl` plumbing; fallback; DR-3's attribution - restoration; correlator + SRA fixes. - _Gate (sticky randomized canary/control cohorts, 24 h, ≥ 10,000 sampled - attempts each; missing telemetry counts as failure):_ **denominator = - all server-observed eligible APS wins**; per-stage rates gated - separately — targeting_set/eligible, bridge_request/targeting_set, - render_accepted/bridge_response_sent, and `cycle_unattributable` rate - < 0.5% (survivorship is thereby visible, not excluded); GAM fill and p95 - latency as **one-sided non-inferiority** (canary not worse than control - by > 2%; improvements pass); billing normalized per attempt and per - thousand attempts vs control; a separate **duplicate-billing invariant** - (zero double `burl` per idempotency key); real-GAM overlap suite green - per its table row. -- **Phase 4 — Structure.** Full layering + both lint rules; plugin - lifecycle; adapters; full slot registry; full messaging migration; final - namespace. - _Gate:_ lints zero exceptions; disposal-inventory leak tests; four-flow - behavioral parity (SSAT, client-Prebid, page-bids, direct). -- **Phase 5 — Decomposition + cutover.** File splits; script-guard - consolidation; bootstrap stub + generated fallback (with error/hang/ - fallback-arbitration tests); **four-flow parity reruns here** (it changed - bootstrap behavior after Phase 4's parity — the review's ordering - point); then the §0 runbook: weight-up, purge, 24 h monitored window, - weight-back rollback. +## 8. Rollout + +Single-release state machine per §0 (sticky-cohort affinity). **The +normative gates table ships with this design as Appendix A**, including +initial thresholds, queries, commands, cohort keys, sample floors, owners, +hold/rollback actions, and the real-GAM suite's operational row. Threshold +changes require reviewed decision records. + +Phases (build milestones inside the one release): + +- **Phase 0 — Identity, schemas, toolchain, decisions.** Release-time + asset materialization; `format_version`; §5.6 schemas writer-off; + toolchain floors; dead expando deletion; drop surfacing; decision + records DR-1..DR-5 (DR-2 now a deployment decision, §6.2–6.5; DR-4 + gates `merge_highest_cpm`; DR-5 splits Phase-2 gates). +- **Phase 1 — Kernel, sessions, minimal messaging, cycle registry, + transactional bootstrap ownership.** +- **Phase 2 — Trace + beacon.** All three issuance paths + renewal + + diagnostic credential + probe mode; four-adapter ingest incl. + `/_ts/trace-auth` in the isolation family; heartbeats. Gates split: + HTTP parity (all adapters) vs persistence (sink-backed). +- **Phase 3 — APS delivery.** As revision 6, plus `notification_sent` + observability and per-flow funnel gating (the `flow` field): expected- + stage tables per flow live in the gates file; denominator = server- + observed eligible APS wins **within sampled/diagnostic traces**; + `cycle_unattributable` gated; one-sided non-inferiority for fill/p95; + duplicate-`burl` invariant via `notification_sent` key hashes. +- **Phase 4 — Structure.** Layering, plugins, adapters, registry, + messaging, namespace; four-flow behavioral parity. +- **Phase 5 — Decomposition + cutover.** File splits; bootstrap stub + + generated fallback (error/hang/arbitration tests); four-flow parity + rerun; **then the full Phase-3 statistical canary/control gate and the + real-GAM suite are repeated on the exact immutable release candidate** + before router weight rises beyond the low-weight canary; then weight-up, + purge, 24 h monitored window. ## 9. Test acceptance matrix -Hermetic CI blocks PRs; the real-GAM suite is release-gating per its gates -row. New/changed rows this revision are marked •. - -| Area | Must cover | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Request cycles | intent-vs-request; • disabled-initial-load `display()` retired at issuance, publisher `refresh()` inside 2 s window attributed to publisher (the exact review case); SRA; `intent_no_request`; overlap quarantine; • no timeout re-arm (drain/destroy/page-end only); stale discard; real-GAM overlap | -| Ack protocol | three-message sequence (• `renderer_document_loaded` envelope); five-field validation; SSAT/client-Prebid/SafeFrame; stale/replayed; after-disposal acks | -| Bridge security | propagation stopped before validation; neither TS nor native Prebid responds to stolen ids; • prior-navigation ids suppressed via RuntimeSession tombstones after NavigationSession disposal; bounded walk; listener order | -| Render semantics | binds per flow; `burl` at accepted; attempt idempotency; • `billing_outcome{billed_then_failed}` as its own event; accepted-but-blank; • `gam_collapsed` emission + guarded resize (authenticated source only; 1×1 wrapper; anchor/fixed guards) | -| Direct `/auction` | • trace header + response `ext.trusted_server.trace`; • per-slot latest-wins with reversed responses; • generation check before each DOM/beacon effect; • `RequestAdsResult` settlement; • server preserves + expands `nurl`/`burl`, client validates | -| Fallback | attributed `gam_empty` only; publisher-initiated never; timeout never renders; SPA cancellation; flag change mid-attempt | -| Mediation | • `[auction].currency` required + per-provider validation (Prebid parse, APS implied USD); • `ext.trusted_server.candidate_id` echo; • mediator-native candidates ordered by intrinsic key (arrival-order shuffle test); • authoritative-field rules (repricing kept, adm-swap → native); provenance fail-closed; • strategy-specific timeouts; both lifecycles | -| Render token | format/CSPRNG/retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scope; capacity → `registry_full`; • tombstone retention to original TTL, cap 256 | -| Trace auth | • auth ≤ 256 B bound accepted, 64-char cap for others; • encoding vectors (kid charset, canonical exp, u32be/u64be length prefixes, unpadded base64url); expiry/skew/max-future; • renewal preserves trace + mode; • previous-key retention ≥ 24 h; rotation; per-group rejection | -| Sampling modes | • signed `unsampled`: client transmits nothing, ingest rejects carried events, stickiness across renewal; • diagnostic requires the operator credential — tester cookie alone must fail; forgery/replay of the credential | -| Beacon | joins on all three issuance paths; per-trace grouping; seq gaps; duplicate fetch/pagehide deduped in the canonical view; • `client_queue_overflow` not in failure denominators; ingest abuse; sendBeacon Blob | -| Ingest/limits | • per-adapter limiter semantics as declared (Fastly fixed-window approximation, Axum token bucket); • at-capacity rejects unseen identities, never evicts active; fail-closed 204 | -| Internal routes | wrong-method 405 + Allow + no-store on every adapter; unknown version 404; no publisher fall-through; dispatch before auth/EC/filters; no forwarding | -| CSP | both media types; opaque/null-origin admission; • policy identity from server-selected report path (forged body URL/version ignored); bucketed aggregation with caps; three-browser capture; • header manifest per version (immutable headers frozen with bytes) | -| Schema | staleness; adversarial corpus ×3 validators; outer tolerance vs exact AAX | -| Runtime ABI | one kernel; exact-release verdicts; late registration; failure isolation; • object-form `definePlugin` release check | -| Plugins | partial-install unwind; async rejection; abort pending; disposer-after-disposal; isolation | -| Lifecycle | `timed_out → present`; session inventories; • unissued intents cancelled by navigation disposal; boot container idempotent field-wise init (• ad-slot script no longer clobbers); • fallback error/hang activation + late-bundle deferral; final-namespace smoke | -| Delivery | unknown hash 410 no-store; exact-match immutable with full directive; • construction-time vector cache (unlisted vector = startup error); cutover rehearsal | -| Sink/monitoring | • sequence-tagged synthetic heartbeats measure rejection + freshness; • canonical views only (raw-join multiplication test); `publisher_domain` naming | -| Failure injection | Amazon runner redirect/hang/CSP/script error → distinct outcomes; EC/filter failure before renderer dispatch | -| Adapter parity | ingest, CSP-report, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | -| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; • `boot.debug` + response `debug` gating; diagnostic completeness per §2.5 | - -## 10. Alternatives considered - -1. Patching APS point-failures without telemetry — rejected (four correct - fixes, still no reliable ads). -2. Always direct-render APS — rejected; kept as the attributed-`gam_empty` - fallback. -3. Single module graph now — rejected for this release; successor option. -4. Big-bang rewrite without phases — rejected (thin safety net). -5. Dropping the ES5 bootstrap — rejected (loses the no-bundle guarantee); - generated fallback keeps it. -6. Timeout-triggered fallback rendering — rejected (uncancelable GPT - requests race late fills). -7. Timeout-based quarantine re-arm (revision 5) — removed: it recreated - the stale-event bug it claimed to fix. -8. N/N−1 compatibility machinery (revisions 3–4) — removed by the §0 - policy. - -## 11. Risks - -- Hard-cutover blast radius (accepted; bounded by the §0 runbook). -- Mediator wire-contract change (`candidate_id` echo) — DR-4 gates - `merge_highest_cpm`. -- Notification triggers become a published contract for PBS-path demand. -- Beacon abuse — capped, origin-checked, fail-closed limited, signed - modes, credentialed diagnostics. -- Registry/limiter memory — explicit capacities, TTLs, reject-at-capacity. -- CSP data is advisory — never a sole rollback signal. -- Sink blindness — heartbeat-based datasource monitoring. -- `[auction].currency` and `winner_selection` are new required config in - mediated deployments — a deliberate startup-error class under §0. +Revision 6's matrix stands, with these added/changed rows: + +| Area | Added coverage | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Request cycles | publisher `display()` under disabled initial load retired at issuance; publisher-display → TS-refresh attribution test; symmetric ambiguity | +| Trace auth | renewal presents current token and preserves mode; renewal-after-expiry fails closed; deterministic sampling (same trace → same mode across concurrent requests) | +| Diagnostic | credential issuance under admin auth + CSRF; fragment→sessionStorage transport; upgrade of an initial sampled trace; auth `exp` capped at credential expiry; forgery/wrong-origin | +| Trace-auth route | four-adapter parity; wrong-method 405; dispatch before filters; no forwarding; own limiter bucket | +| Affinity | sticky-cohort coherence: new-pool HTML never fetches control-pool assets/APIs (cache-key + routing test); rollback binding prevalidation | +| Join keys | `auction_id` echo on all three paths; attempt-grain join uniqueness under repeated same-slot auctions; `release_id` cohort attribution | +| Funnels | `flow` field set per path; per-flow expected-stage conformance (SSAT, prebid, page-bids, direct, fallback) | +| Tombstones | union capacity 320; unexpired never evicted; >320 registrations then late oldest-id request suppressed; `registry_full` on refusal | +| Ack per path | all four G4b sequences incl. adm reporter snippet; per-path document/runner deadlines; cancellation on navigation | +| Terminal states | exactly one terminal per attempt; post-accept runner failure emits observation + `billing_outcome`, no second terminal | +| Notifications | `notification_sent` emitted per dispatch; duplicate-key-hash query returns zero in canary; direct-flow `(bid_id)` identity; server preserves/expands `nurl`/`burl`; client validates | +| Bootstrap | inert-install + commit flip; throw after each checkpoint unwinds; watchdog claims only from failed/stuck; fallback-then-late-bundle deferral | +| Overflow | out-of-band counter; coalesced overflow event in next flush; no recursion at full queue | +| Heartbeats | probe mode not sampled out; excluded from product denominators; freshness/loss queries from `expected_seq` | +| CSP ingest | pre-buffer caps (8 KiB / 10 reports / 256 chars / depth 4); both media types; opaque origin; policy-id path identity; aggregate schema rows | +| Mediation | required upstream id bounds; fingerprint fallback determinism under arrival shuffle; duplicate-id dedup; adm-swap reclassification; APS + non-USD startup error | +| Limiter | TTL reclaim under saturation; unknown-address bucket; Fastly overshoot bound; XFF hop selection | +| Perf | marks present; vector contents; heap budget; inconclusive-rerun policy | +| Lint | member-expression access to `googletag`/`pbjs` via `window`/`globalThis`/`self`/aliases caught outside adapters | +| Kill switch | pre-commit attempts cancelled; post-commit attempts run to terminal; snapshot semantics | + +## 10. Alternatives / 11. Risks + +As revision 6, plus: **rejected** — per-request Fastly concatenation +(replaced by release-time materialization); trusting client-asserted +sampling on renewal (replaced by token-presentation renewal); FIFO +tombstone eviction (replaced by union capacity with refusal). Risk added: +sticky-cohort routing is new infrastructure the cutover depends on — it is +Phase 0 work and its coherence test is release-gating. ## 12. Success criteria -1. APS creatives render in each configured flow (SSAT, client-Prebid, - page-bids, direct), hermetically and in the release-gating real-GAM - suite. -2. Every §2 failure maps to its §2.5 signal; **diagnostic mode names the - failing class from one page load including A1–A4 via the `boot.debug` / - response-`debug` envelopes**; §5.7 SLIs hold on sink-backed - deployments. -3. Lints (both rules) zero exceptions; stateful sharing only via the - registry; exact-release mismatches quarantine loudly. -4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or - generated. -5. Attempt counts are keyed `(trace_id, nav_gen, refresh_gen, slot)` - (traces stay navigation-scoped); no double counting; orphan recovery - has a non-vacuous test; G4a holds including the no-timeout-re-arm rule. -6. The only TSJS-owned global is `window.tsjs` (§7.4 final shape); no - expandos; legacy names gone at cutover. -7. §7.10 budgets hold on the dedicated pinned workflow. -8. No existing warning lost; issue-surfacing conditions log `warn`+ with - the beacon reason code. -9. TypeScript floor matches resolved 5.9; `prebid.js` pin documented with - the deployed bundle. -10. `nurl`/`burl` only on carrying paths at their G4d binds, idempotent - per attempt; APS fires neither; duplicate-billing invariant holds. -11. Trace-bearing responses are `private, no-store`; authorizations are - per-trace, signed, mode-carrying (`sampled|unsampled|diagnostic`), - renewal-capable; unsampled traces transmit nothing. -12. The cutover runbook rehearsed (weight switch, purge, rollback). +Revision 6's criteria with these corrections: (2) diagnostic completeness +is achieved via the §2.5 gate split (content vs volume); (5) attempt +counts keyed `(trace_id, nav_gen, refresh_gen, slot)`; (10) the +duplicate-`burl` invariant is measured via `notification_sent` key hashes +in production and by hermetic tests, with external billing reconciliation +as backstop; (add 13) the Phase-3 statistical and real-GAM gates pass on +the exact immutable Phase-5 release candidate before weight-up; (add 14) +the Appendix A gates table shipped with this design and every later change +carries a reviewed decision record; (add 15) the baseline APS fix behaviors +are re-implemented in the target architecture with the baseline browser +tests passing unmodified as the conformance pin. ## 13. Open questions -Promoted to Phase 0 decision records: mediator presence (DR-1), -script-creative share (DR-2), #922 vs #997 (DR-3), candidate-id echo owner -(DR-4), non-Fastly sinks (DR-5). Remaining open: does Amazon expose any -creative-completion acknowledgement that could add a post-`render_accepted` -state under a new name (future enhancement)? +Only one remains outside the decision records: does Amazon expose any +creative-completion acknowledgement that could add a +post-`render_accepted` state under a new name (future enhancement)? + +--- + +## Appendix A — Normative rollout gates (initial values) + +Owners are roles: **RO** = release owner, **QA** = QA owner, **OPS** = +release owner's on-call. Assignment key for canary/control = +sticky cohort (`ts-rel`), randomized at HTML request, per §0. All +production queries run against canonical views only (§5.5). "Hold" = +router weight frozen; "Rollback" = weight to previous release + re-purge. +Changing any row requires a reviewed decision record. + +### A.1 Phase gates + +| Phase | Gate | Query / test command | Denominator | Floor | Threshold | Window | Owner | Action | +| ----- | -------------------------- | --------------------------------------------------------------------- | ---------------------------------------------- | ------------- | --------------------------------- | ------ | ----- | -------- | +| 0 | Dark-pool health | probe suite vs dark pool (all four adapters) | probe requests | 1,000 | 100% expected responses | 24 h | OPS | Hold | +| 0 | Schema validation | synthetic writes to `ts_client_events` + auction rows | synthetic rows | 10,000 | rejection < 0.1% | 24 h | RO | Hold | +| 0 | Asset identity | probe: every manifest hash 200-immutable; unknown hash 410 `no-store` | probed hashes | all | 0 misses / 0 wrong-status | once | QA | Hold | +| 1 | ABI cleanliness | probe pages: `abi_mismatch` + `bundle_partial` counters | probe page loads | 1,000 | 0 | 24 h | QA | Hold | +| 1 | Bootstrap ownership | hermetic: throw-after-each-checkpoint suite | checkpoints | all | 100% unwind-to-`failed` | CI | QA | Hold | +| 2 | Ingest HTTP parity | parity suite vs all four adapters (routes, 405s, limits, 204s) | parity cases | all | 100% | CI | QA | Hold | +| 2 | Persistence (sink-backed) | acceptance ≥ 99%; dedup exactly-once per `(trace, seq)` | probe batches | 10,000 evts | as stated | 24 h | OPS | Hold | +| 2 | Heartbeat pipeline | freshness lag; `expected_seq` loss | probe heartbeats | 1,000 | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | +| 3 | APS funnel (per flow) | per-`flow` stage rates from `ts_render_attempts_v` (table A.2) | eligible APS wins in sampled+diagnostic traces | 10,000/cohort | per A.2 | 24 h | RO | Rollback | +| 3 | Attribution soundness | `cycle_unattributable` rate | attributable-candidate cycles | 10,000 | < 0.5% | 24 h | RO | Rollback | +| 3 | GAM fill (non-inferiority) | canary fill vs control | cohort ad requests | 10,000 | canary ≥ control − 2% (one-sided) | 24 h | RO | Rollback | +| 3 | Latency (non-inferiority) | canary p95 bids-to-display vs control | cohort attempts | 10,000 | canary ≤ control × 1.02 | 24 h | RO | Rollback | +| 3 | Billing | GAM/server-side revenue per 1,000 attempts, canary vs control | cohort attempts | 10,000 | canary ≥ control − 2% (one-sided) | 24 h | RO | Rollback | +| 3 | Duplicate `burl` | duplicate `notification_sent{burl}` per `id_key_hash` | burl dispatches | 1,000 | 0 | 24 h | RO | Rollback | +| 4 | Layering | both lint rules; disposal-inventory leak suite | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | +| 4 | Four-flow parity | hermetic parity: SSAT, prebid, page-bids, direct | parity cases | all | 100% | CI | QA | Hold | +| 5 | Parity rerun + budgets | four-flow parity; §7.10 budgets on pinned workflow | — | — | 100% / within tolerance | CI | QA | Hold | +| 5 | RC re-canary | repeat all Phase-3 rows on the immutable RC | as Phase 3 | as Phase 3 | as Phase 3 | 24 h | RO | Rollback | +| 5 | Cutover monitor | §5.7 SLIs post-weight-up | production traffic | — | SLIs green | 24 h | OPS | Rollback | + +Low-volume handling: a production gate that cannot reach its floor within +its window is **inconclusive** — extend the window once; a second +inconclusive result is a Hold, never a pass. + +### A.2 Expected stages per flow (Phase 3 funnel) + +| Flow | Expected sequence | Stage thresholds | +| --------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_accepted` | each stage ≥ 95% of prior; document-load ≥ 99%; runner fail+timeout ≤ 1% | +| prebid | same as ssat (keyed by Prebid `adId`) | same | +| page_bids | same as ssat (after SPA navigation) | same | +| direct | `render_attempt → renderer_document_loaded → render_accepted` (no bridge stages) | document-load ≥ 99%; accepted ≥ 95% of attempts | +| fallback | `gam_empty → fallback_start → renderer_document_loaded → render_accepted` | accepted ≥ 95% of fallback starts | + +### A.3 Real-GAM suite (operational row) + +| Field | Value | +| ------------------ | -------------------------------------------------------------------------------------- | +| Workflow | `real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | +| Topologies | one per flow in A.2, plus publisher-overlap and disabled-initial-load formation (G4a) | +| Browsers | Chromium, Firefox, WebKit (CSP/opaque-origin rows); Chromium (funnel rows) | +| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | +| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | +| Command | `npx playwright test --config real-gam.config.ts` from the browser test package | +| Artifact | Playwright HTML report + trace zips, uploaded as workflow artifacts, retained 90 days | +| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | +| Approval evidence | green workflow run URL linked in the release checklist, signed off by RO | From b1611680ddce4bd4e766fea40d571017e9a6423d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:51:58 -0700 Subject: [PATCH 009/194] Rewrite design spec self-contained for the seventh review round Revision 8 inlines every normative contract (no revision references remain) and resolves the seventh round: beacon and CSP transports move to credentials same-origin so authenticated sticky-cohort affinity holds for every request class, the ts-rel cookie becomes an opaque HMAC token with attribute/TTL/reassignment rules, config blobs are hash-verified against the deployment manifest at startup, auction_id becomes the cross-tier equality key with generations client-side only, canary/control attribution moves to pool-specific datasource tokens, statistical gates use sampled traces only with one-sided 95 percent confidence bounds on relative differences and per-flow floors (rare flows gate hermetically), Appendix A rows name checked-in pipes, scripts, and workflows with a concrete cutover-monitor threshold and zero-rejection deterministic schema writes, the duplicate-burl invariant becomes hermetic proof plus billing reconciliation with telemetry as a detection alarm, the browser stops computing hashes in favor of a server-minted notif_id, every datasource gets its own probe and freshness query, CSP and ops sinks get complete schemas, settings, and a multi-target sink handle, origin policy becomes per route family, same-class zero-request intents are superseded by any later request-capable intent, the auction client returns a discriminated error result, the bootstrap watchdog aborts and unwinds under an owner generation before fallback claims, flow gains a system value with a generated validity matrix, fallback becomes a child attempt with parent_flow, id-less or duplicate bids are rejected instead of fingerprinted, the drop enum is exhaustive over baseline producers with a compile-time mapping test, the Fastly overshoot claim is withdrawn in favor of documented burst behavior, diagnostic upgrade becomes the sole authenticated mode transition with fragment clearing and in-memory-only credential storage plus pre-upgrade buffering, sampling gets an exact u64 threshold algorithm, totals and overflow rows gain row_kind with nullable fields, the kill-switch commit point moves before the first irreversible action including nurl, billing_outcome is removed for lack of an honest producer, the plugin-level dispose hook is removed in favor of ctx.onDispose, and the TypeScript gate uses a checked-in npm script with --no-install. --- ...s-render-fix-and-tsjs-resilience-design.md | 1690 +++++++++++------ 1 file changed, 1090 insertions(+), 600 deletions(-) diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index 001011d91..74e08c867 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1,60 +1,83 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 7 — reworked after the sixth review round. +- **Status:** revision 8 — reworked after the seventh review round and made + fully self-contained: no contract in this document is defined by reference + to an earlier revision. - **Date:** 2026-08-04 - **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed GAM shells"). All file:line citations refer to this commit. -- **Inputs:** three code audits; design reviews of revisions 1–6; open issues +- **Inputs:** three code audits; design reviews of revisions 1–7; open issues #926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997. -- **Normative gates:** the initial rollout-gates table ships as - **Appendix A of this document** (one file, one review surface); changes to - it require reviewed decision records so thresholds cannot be chosen after - observing results. +- **Normative gates:** the initial rollout-gates table is **Appendix A**; + changes require reviewed decision records so thresholds cannot be chosen + after observing results. - **Adoption stance for the baseline APS fixes (`248fe9558`):** this design adopts their **contracts** — the MessageChannel handshake semantics, the collapsed-shell remediation behavior, the consolidated bridge branch — and - **re-implements them inside the target architecture** (the messaging - module owns the channel protocol, the render engine owns the resize, the - rebuilt `render_bridge` module owns the branch). The patch code itself is - not carried forward through the refactor; the baseline's browser tests are - retained as the conformance suite that pins the adopted behavior while the - implementation is replaced. + **re-implements them inside the target architecture** (the messaging module + owns the channel protocol, the render engine owns the resize, the rebuilt + `render_bridge` module owns the branch). The patch code is not carried + forward; the baseline's browser tests are retained unmodified as the + conformance suite pinning the adopted behavior. ## 0. Release policy: coordinated hard cutover One coordinated release: -- Server, TSJS bundles, config, and HTML ship under one **`release_id`**. - **No N/N−1**; in-flight clients may fail at cutover — accepted and stated. -- **Exact release matching**; config `format_version` exact-match; rollback = - redeploy the previous release with its own config. -- **Config is a release-time input** under this policy: the config blob and - binary publish together, so the enabled module vectors are known at - release publication (this powers §G5 asset materialization). -- Assets: embedded only; hashed pathnames for cache identity; unknown hash - → `410`, `no-store`. -- **Rollout state machine with release affinity.** A deployment manifest - binds each pool to immutable `{release_id, config_store, config_key, -config_hash}` (rollback binding prevalidated). The new pool comes up fully - enabled, reachable only by probes. Canarying uses a **sticky cohort - token**: the router assigns `ts-rel=` on the HTML response, - routes every subsequent request (assets, APIs, beacons) by it, and cache - keys include it — router weights alone apply per request and would mix - pools, so affinity is what makes a canary request **coherent** end to end. - Router weight over sticky cohorts is the sole activation primitive; flags - are in-pool emergency kill switches only. Cutover = weight 100% + CDN - purge; rollback = weight back + re-purge. +- Server, TSJS bundles, config, and HTML ship under one **`release_id`** + (git tag / build hash). **No N/N−1**; in-flight clients may fail at + cutover — accepted and stated, not mitigated. +- **Exact release matching**: kernel, services, plugins, and the install + manifest carry the same `release_id`; mismatch is a refusal. +- **Config is a release-time, content-verified input.** The config blob gains + a top-level `format_version` (exact match required). Publish order: blob + first, deployment manifest second. The manifest binds each pool to + immutable `{release_id, config_store, config_key, config_hash}`, and the + binary **verifies the loaded blob's hash against the manifest at + startup** — a mismatch is a startup failure, so a config overwrite cannot + mutate a supposedly immutable release or invalidate pre-materialized asset + vectors. Rollback = redeploy the previous release with its own verified + config; the rollback binding is prevalidated. +- **Assets:** binaries embed only their release's artifacts; hashed pathnames + exist for cache identity; unknown hash → `410 Gone`, `no-store`. +- **Rollout state machine with authenticated release affinity.** The new + pool comes up fully enabled, reachable only by probes. Canarying uses a + **sticky, opaque, authenticated cohort token**: the router sets `ts-rel` + on the HTML response — an HMAC-signed opaque value binding + `{publisher_host, release_id, cohort, exp}` (attributes: `Secure; +HttpOnly; SameSite=Lax; Path=/`; TTL 24 h) — and routes every subsequent + request by the **validated** token; invalid, expired, forged, or + non-allowlisted tokens route to control and are reissued; tokens for + retired releases are reassigned on next HTML response after rollback. + Cache keys use the post-validation release label (bounded cardinality; + raw cookie values never key caches). A plain readable release id would + let any visitor opt into the dark pool and would hand cache-key + cardinality to attackers — hence opaque and authenticated. Because + affinity rides a cookie, **the beacon and CSP-report transports use + `credentials: "same-origin"`** (not `omit`): the cookie exists for the + routing layer only; application handlers still derive no identity from + it. Router weight over sticky cohorts is the sole activation primitive; + flags are in-pool emergency kill switches. Cutover = weight 100% + CDN + purge; rollback = weight back + re-purge. The affinity acceptance test + covers HTML, assets, APIs, **beacons, and CSP reports**. +- **Canary/control discrimination is infrastructure-attributed:** each pool + writes telemetry with **pool-specific datasource tokens**, so cohort + attribution comes from the write identity, not from in-row fields the + control binary (the baseline) does not emit; in-row `release_id` from the + new pool is secondary confirmation. ## 1. Problem statement -APS demand is fully integrated server-side, yet APS creatives do not appear -reliably. Four serial fixes (the `bid.meta` carrier, the decoupled shim, the -`hb_adid` fallback, the baseline PUC/collapsed-shell fix) each survived -review; the pattern is the finding: **multiple independent failure points, -most failing silently**, with no client→server signal about which fired. -The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths, -duplicated ES5/TS logic, inverted layering, ~100 error-swallowing catches) -is the same problem structurally. +APS demand is fully integrated server-side — the edge runs the APS OpenRTB +auction, wins bids, and ships a typed renderer descriptor — yet APS +creatives do not appear reliably. Four serial fixes (the `bid.meta` +carrier, the decoupled shim, the `hb_adid` fallback, the baseline +PUC/collapsed-shell fix) each survived review; the pattern is the finding: +**multiple independent failure points, most failing silently**, with no +client→server signal about which fired. The TSJS library (56 files, +~11,900 lines, two ~1,800-line monoliths, duplicated ES5/TS logic, +inverted layering, ~100 error-swallowing catches) is the same problem +structurally. ### Non-goals @@ -66,8 +89,8 @@ is the same problem structurally. ## 2. Why APS does not render — evidence Flows: (a) SSAT via `window.tsjs.bids`; (b) GAM + client `trustedServer` -adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction`. Only (d) renders -an APS descriptor without GAM. +Prebid adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction`. Only (d) +renders an APS descriptor without GAM. ### 2.1 Admission @@ -94,7 +117,7 @@ an APS descriptor without GAM. | C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` | | C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` | | C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated. | `gpt/index.ts:1729` | -| C6 | The renderer CSP can kill creatives after "ready". | `aps.rs:49` | +| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` | | C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` | ### 2.4 Observability @@ -104,13 +127,13 @@ server-side to one that painted. ### 2.5 Failure → signal mapping (normative) -Two distinct gates, precisely separated: the **tester cookie** (explicitly +Two gates, precisely separated: the **tester cookie** (explicitly non-security, `tester_cookie.rs:3`) gates **debug content** — the `tsjs.boot.debug` envelope and the page-bids/`/auction` `ext.trusted_server.debug` fields, the same sensitivity class as the -existing tester-gated `ts-debug` HTML comment. The **diagnostic credential** -(§5.3) gates **telemetry volume** (unsampled mode). The tester cookie never -affects sampling; the credential never gates mere content. +existing tester-gated `ts-debug` comment. The **diagnostic credential** +(§5.3) gates **telemetry volume**. The cookie never affects sampling; the +credential never gates mere content. | Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface | | ------- | ----------------------------------------- | ------------------------------------- | ------------------------------- | @@ -118,9 +141,9 @@ affects sampling; the credential never gates mere content. | A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary | | A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary | | A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` | -| B1/B2 | `bridge_request{matched:false}` | join via trace | console warn | +| B1/B2 | `bridge_request{matched:false}` | join via trace (§G1) | console warn | | C1 | `gam_empty` then no `bridge_request` | join via trace | console warn | -| C2 | `render_fail{renderer_document_no_load}` | route counters (`ts_ops_counters`) | console warn | +| C2 | `render_fail{renderer_document_no_load}` | `ts_ops_counters` | console warn | | C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn | | C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn | | C5 | — (fixed at baseline) | — | — | @@ -129,8 +152,8 @@ affects sampling; the credential never gates mere content. ## 3. The GPT and baseline reality -1. Bootstrap-first hybrid; the bundle's handoff/initial-load code is dead in - production. +1. Bootstrap-first hybrid; the bundle's handoff/initial-load code is dead + in production. 2. #922 merge loss (`0dc9b19a9`); PR #997 is the apparent replacement. 3. TS refreshes never pass `changeCorrelator: false`. 4. `enableSingleRequest()` called blind after publisher `enableServices()`. @@ -140,606 +163,1064 @@ affects sampling; the credential never gates mere content. guarantee**; `slotRenderEnded` = code injected, not resources loaded; `responseIdentifier` identifies responses only. 8. **`display()` under disabled initial load creates no request — for any - caller.** GPT's behavior is caller-independent (`gpt/index.ts:1175`, - `ad_init.test.ts:1201-1263`); the G4a rules therefore apply to publisher - `display()` exactly as to TS `display()`. + caller** (`gpt/index.ts:1175`, `ad_init.test.ts:1201-1263`); GPT's + behavior is caller-independent. 9. `slotRenderEnded` registration gated behind `!ts.servicesEnabled` (`gpt/index.ts:1091`); G4a needs unconditional early subscription. 10. Baseline fix `248fe9558`: MessageChannel APS-PUC handshake - (`aps.rs:65-125`, `aps/render.ts:415-437`; reply still terminates inside - the PUC frame), collapsed-shell resize (`gpt/index.ts:217`), C5 + (`aps.rs:65-125`, `aps/render.ts:415-437`; the reply still terminates + inside the PUC frame), collapsed-shell resize (`gpt/index.ts:217`), C5 consolidated, real-PUC browser test added. 11. The bridge keeps consumed-id tombstones for security - (`gpt/index.ts:1527`); G2 preserves that across navigations. + (`gpt/index.ts:1527`). 12. The tester cookie is not a security control (`tester_cookie.rs:3`). -13. **Fastly constructs application state per request** (`app.rs:146`) — no - cross-request in-memory state may be assumed on that adapter. +13. **Fastly constructs application state per request** (`app.rs:146`); + its platform rate counter is a 60 s fixed window with separate + lookup/increment (`rate_limiter.rs:40`). +14. The baseline auction client collapses every failure into an empty + array (`core/auction.ts:185-224`): absent fetch, timeout, network + error, non-2xx, wrong content type, malformed body, and a genuine + zero-bid auction are indistinguishable to callers. ## 4. Design gates -### G1 — Trace identity, sampling, and correlation - -- Client-visible auction id (EC-derived, `publisher.rs:3237`) is never - ingested. Initial-HTML telemetry precedes page JS, so correlation is - minted by whoever acts first: server for `nav_gen 0` (trace + signed - authorization in `tsjs.boot`); client afterwards, via `X-TSJS-Trace-Id` - on page-bids (GET) and on the `/auction` POST, echoed back with the - signed authorization (`ext.trusted_server.trace = {trace_id, auth, -auction_id}` on JSON/OpenRTB responses). -- **Sampling is a deterministic keyed function**, not a coin flip: - `mode = sampled iff HMAC(sampling_key, trace_id) < sample_rate` — so - concurrent requests presenting the same trace always derive the same - mode, and first issuance needs no shared state (Fastly is per-request, - §3.13). -- **Cross-tier join key (closing the row-multiplication gap):** the server - echoes its telemetry `auction_id` to the client (boot / response - extensions); the client stamps `auction_id` on every event of attempts - born from that auction. Server rows additionally carry `release_id`. - Canonical joins run on `(publisher_domain, trace_id, auction_id, slot, -refresh_gen)` at attempt grain; canary/control comparison uses the - server-side `release_id`. -- Cache-privacy invariant: traces/authorizations only in per-request - auction-bearing responses; such HTML is `private, no-store`. +### G1 — Trace identity, sampling, correlation + +- The client-visible auction id is EC-derived (`publisher.rs:3237`) and + never ingested. Initial-HTML telemetry precedes page JS + (`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by + whoever acts first: the **server** for `nav_gen 0` (trace + signed + authorization in `tsjs.boot`); the **client** afterwards via + `X-TSJS-Trace-Id` on page-bids (GET) and the `/auction` POST, echoed + back with the authorization and the server's telemetry auction id: + `ext.trusted_server.trace = {trace_id, auth, auction_id}`. +- **Deterministic keyed sampling, numerically exact:** take the first + 8 bytes of `HMAC-SHA-256(sampling_key, trace_id)` as a big-endian u64; + `mode = sampled` iff `u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate` + must be finite and in `[0, 1]` (validated at load; 0 → nothing sampled, + 1 → everything). Concurrent requests for one trace always derive the + same mode with no shared state (§3.13). +- **Cross-tier join:** the equality key is the globally unique + **`auction_id`** (server-minted telemetry UUID), echoed to the client + and stamped on every event of attempts born from that auction. + Generations (`nav_gen`, `refresh_gen`) exist **client-side only**, for + attempt aggregation — auction rows do not carry them. Canonical join: + `(publisher_domain, trace_id, auction_id)`, attempt grain added from + client events. +- **Cache-privacy invariant:** traces/authorizations only in per-request + auction-bearing responses; such HTML is `private, no-store`, no + validators; by construction and by test. - Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry - `{nav_gen, refresh_gen, seq, flow, auction_id?}`. **`flow` is a closed - field** `ssat | prebid | page_bids | direct | fallback` set by the - attempt owner — the per-flow funnels in the gates table depend on it. + `{nav_gen, refresh_gen, seq, flow, auction_id?}`. **`flow`** is closed: + `ssat | prebid | page_bids | direct | fallback | system` — `system` for + heartbeat and overflow events, which have no render flow; the generated + per-event validity matrix (§5.1) says which events may carry which + flows. - Traces are navigation-scoped; attempt counts key on `(trace_id, nav_gen, refresh_gen, slot)`. ### G2 — Render identity -- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte. Markup bids: - existing fallback chain. Renderer-only bids: server-minted token - `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, TTL 15 min, - one-time consumption. -- **Registry + tombstones, one capacity, no unexpired eviction:** the - bridge-reservation store holds live registrations and tombstones - (consumed / stale / navigation-disposed ids) in one bounded structure — - **capacity 320 for the union**; expired entries are pruned; **unexpired - entries are never evicted**; when the union is at capacity, **new - registration is refused** with `registry_full`. A late prior-navigation - bridge request therefore always meets suppression until its id's original - TTL passes (preserving `gpt/index.ts:1527`). Test: >320 registrations, - then a late request for the oldest unexpired id. -- Client-Prebid keeps Prebid's `adId`; one store serves both paths. - -### G3 — Runtime ABI (exact-release) - -- Kernel only in `tsjs-core`; publishes - `tsjs._internal = {release_id, registry}`; frozen after boot; core - services constructed at boot; plugins register via the object-form API - (§7.6) whose `release` is a build-generated constant; `registry.get` - succeeds only on `release_id` equality; mismatch quarantines - (`abi_mismatch`/`bundle_partial`) with a console error. -- Boundary enforcement: `import/no-restricted-paths` for layering plus - **`no-restricted-properties`/`no-restricted-syntax`** rules covering - `window`, `globalThis`, `self`, and local aliases for `googletag`/`pbjs` - access outside `adapters/` (`no-restricted-globals` cannot catch member - expressions). Adapters are the only access to **external ad-tech - globals**; the kernel and messaging module necessarily touch - `window.tsjs`, listeners, and `postMessage`. +- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte + (`publisher.rs:3355`; the PUC fetches `?uuid=`, `gpt/index.ts:1772`). + Markup bids: existing fallback chain. Renderer-only bids: server-minted + token `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, + cross-auction uniqueness probabilistic (36¹² ≈ 4.7×10¹⁸) and harmless + via scoping; TTL 15 min; one-time consumption. +- **Reservation store — one capacity, no unexpired eviction:** live + registrations and tombstones (consumed / stale / navigation-disposed + ids) share one bounded structure, **union capacity 320**; expired + entries are pruned; **unexpired entries are never evicted**; at + capacity, new registration is refused with `registry_full`. A late + prior-navigation bridge request always meets suppression until its id's + original TTL passes (preserving `gpt/index.ts:1527`). Test: >320 + registrations, then a late request for the oldest unexpired id. +- The client-Prebid path keeps Prebid's generated `adId`; one store + serves both paths. Non-APS cache-path byte-identity regression tests. + +### G3 — Runtime ABI under the IIFE build (exact-release) + +Every entry point is a self-contained IIFE with inlined imports +(`build-all.mjs:46`, `bundle.rs:23`) — imports never share state across +bundles (live defect: `core/context.ts:11` vs `permutive/index.ts:102`). + +- The kernel ships only in `tsjs-core`, publishes + `tsjs._internal = {release_id, registry}` once (window sentinel), + freezes `_internal` after boot, and constructs/registers core services + (event bus, beacon queue, sessions, slot registry, render engine) + during boot; integrations register integration-scoped services during + `install()`. +- **Exact release matching:** every registration carries `release_id` + (plugins via the §7.6 object API whose `release` is a build-generated + constant); `registry.get(name)` succeeds only on equality; mismatch + quarantines (`abi_mismatch` service / `bundle_partial` plugin) with a + console error. +- Stateful access only via the registry at call time; stateless helpers + may inline. +- **Boundary enforcement:** `import/no-restricted-paths` for layering + **plus** `no-restricted-properties`/`no-restricted-syntax` rules + catching member-expression access to `googletag`/`pbjs` through + `window`, `globalThis`, `self`, and local aliases outside `adapters/` + (`no-restricted-globals` cannot catch member expressions). Adapters are + the only access to **external ad-tech globals**; kernel and messaging + necessarily touch `window.tsjs`, listeners, and `postMessage`. ### G4 — Render lifecycle **G4a — Physical request cycles.** -- Intents recorded for both classes (`ts | publisher`) in one causal queue. - **Any `display()` issued while initial load is disabled is retired at - issuance regardless of caller** (GPT's no-request behavior is - caller-independent, §3.8) — a stale publisher `display()` intent can no - more poison a later TS `refresh()` match than the reverse. Hindsight - zero-request intents (`refresh()` on a never-displayed slot) expire at - 2 s with `intent_no_request`; while one is pending, any opposite-class - intent makes the next `slotRequested` ambiguous → quarantine. The - ambiguity rule is symmetric. -- Cycles open only on `slotRequested`, matched to the causal queue head; - SRA yields one per slot per batch; cycles close on `slotRenderEnded`; - `responseIdentifier` dedups during drain. -- One outstanding TS cycle per slot; one queued replacement (coalescing). -- Attribution requires exactly one outstanding TS cycle and no overlap; +- **Intents, both classes, one causal queue.** Every observable + initiation — TS and wrapped publisher `display()`/`refresh()` — records + an intent in causal order, classified `ts | publisher`. Any + `display()` issued while initial load is disabled is **retired at + issuance regardless of caller** (GPT is caller-independent, §3.8) — it + never enters the matcher. Hindsight zero-request intents (`refresh()` + on a never-displayed slot) expire at 2 s with `intent_no_request`; + **any later request-capable intent — same class or opposite — + supersedes a pending uncertain intent immediately**, and if the + uncertain intent's request could still legitimately be in flight + (within its 2 s bound), the next `slotRequested` is ambiguous and the + slot quarantines. A stale no-op `refresh()` can therefore never steal + a later `display()`'s request in either direction, TS→TS, + publisher→publisher, or across classes. +- **Cycles** open only on `slotRequested`, matched to the causal queue + head; SRA batching yields one per slot per batch; cycles close on + `slotRenderEnded`; `responseIdentifier` deduplicates responses during + drain (it never attributes initiation). +- **Serialization:** at most one outstanding TS cycle per slot; one + queued TS replacement (later intents coalesce). +- **Attribution:** a `slotRenderEnded` is attributable iff exactly one TS + cycle is outstanding and no publisher/untracked request overlaps; otherwise quarantine (`cycle_unattributable`), fail closed. -- **No timeout re-arm.** Re-arm only on count-based drain, safe TS-owned - destroy/redefine, or page end. Unissued intents are NavigationSession - children; physical cycle/drain state is RuntimeSession. -- Deterministic-harness CI plus the release-gating real-GAM suite (scope - enumerated in the gates table: per-flow topologies, expected sequences, - browsers, fixtures, commands, artifacts, approvals — success criterion 1 - refers to that enumeration). - -**G4b — Acknowledgement, specified per render path.** Four normative -sequences; each names its nonce/token producer, transport into the owned -frame, authenticated acceptance observation, cancellation, and separate -document/runner deadlines (document 3 s, runner 10 s, adm 5 s): - -1. **APS-PUC** (baseline transport): bridge mints the per-attempt 128-bit - nonce; MessageChannel into the renderer document (`ports.length` - checks, exact-key replies, one-shot latch, port close); the document - posts authenticated `renderer_document_loaded` then - `render_accepted | render_failed{reason}` **to the top window**; the - kernel validates source ownership, nonce, token, `nav_gen`, - `refresh_gen` before transitions or notifications. -2. **Generic ADM/cache-PUC**: the bridge's display renderer creates the - sandboxed adm frame with an injected reporter snippet; the reporter - posts authenticated `adm_document_loaded{nonce}` to the top window on - document load; acceptance = that message (baseline merely appends an - iframe with no observation). -3. **Direct APS** (`renderApsCreative`): the kernel is the frame parent — - the baseline parent-postMessage handshake (`ports.length === 0` branch) - is already kernel-observed; same three messages, same validation. -4. **Direct ADM/cache**: as (2), with the kernel as parent. - -Cancellation for all four: navigation/supersession invalidates the nonce; -late acks are discarded with `stale_navigation`. - -**G4c — Honest observations; one terminal state.** Observations: -`gam_nonempty`, `gam_empty`, `gam_collapsed{action: resized | guarded, -reason}` (observation and remediation are separate — a guarded anchor/fixed -case is still observed), `renderer_document_loaded`, `runner_loaded`, -`runner_failed`, `adm_document_loaded`. **An attempt has exactly one -terminal state: `accepted | failed{reason} | no_bid | cancelled`.** -Post-acceptance runner failure is an observation plus the -`billing_outcome{billed_then_failed}` event — never a second terminal -transition. No observation claims paint. The baseline resize is a -sanctioned, guarded exception to the no-foreign-DOM-mutation rule. +- **No timeout re-arm.** Physical cycle/drain state lives in the + RuntimeSession slot record; unissued intents are NavigationSession + children (cancelled by navigation disposal). A quarantined or stale + slot re-arms only on count-based drain, safe TS-owned + destroy/redefine, or page end. Timeouts emit diagnostics and never + restore attribution. Late stale events are matched and discarded + (`stale_navigation`). +- Deterministic-harness CI plus the release-gating real-GAM suite + (topologies enumerated in Appendix A.3). + +**G4b — Acknowledgement, per render path.** Four normative sequences; +each names its nonce producer, transport, authenticated acceptance +observation, cancellation, and deadlines (document 3 s, runner 10 s, +adm 5 s). All nonces are per-attempt 128-bit CSPRNG values minted by the +attempt owner; the kernel validates, in order: source ownership (§6.8 +walk), nonce, token, `nav_gen`, `refresh_gen` — before any transition or +notification. Navigation/supersession invalidates the nonce; late acks → +`stale_navigation`. + +1. **APS-PUC** (baseline transport): bridge mints the nonce; + MessageChannel into the renderer document (`ports.length` checks, + exact-key replies, one-shot `accepted` latch, port close — + `aps.rs:65-125`, `aps/render.ts:415-437`); the document posts + authenticated `renderer_document_loaded` then + `render_accepted | render_failed{reason}` to the top window. +2. **Generic ADM/cache-PUC:** the display renderer creates the sandboxed + adm frame with an injected reporter snippet that posts authenticated + `adm_document_loaded{nonce}` on document load; acceptance = that + message (the baseline merely appends an iframe with no observation). +3. **Direct APS** (`renderApsCreative`): the kernel is the frame parent; + the baseline parent-postMessage branch (`ports.length === 0`) is + already kernel-observed; same three messages, same validation. +4. **Direct ADM/cache:** as (2) with the kernel as parent. + +**G4c — Honest observations; one terminal state.** Inline-adm frames are +sandboxed `srcdoc` without `allow-same-origin` (`gpt/index.ts:510`) — +opaque; geometry proves nothing. Observations: `gam_nonempty`, +`gam_empty`, `gam_collapsed{action: resized | guarded, reason?}` +(observation and remediation separate), `renderer_document_loaded`, +`runner_loaded`, `runner_failed`, `adm_document_loaded`. **An attempt has +exactly one terminal state: `accepted | failed{reason} | no_bid | +cancelled`.** Post-acceptance runner failure is an observation only — +there is **no** `billing_outcome` event: no path has an honest producer +for a post-accept billing-failure claim (APS is excluded from +notifications and opaque frames offer no authenticated post-accept +signal), so the design does not pretend otherwise. No observation claims +paint; there is no `render_confirmed`. The baseline resize +(`gpt/index.ts:217`) is a sanctioned, guarded exception to the +no-foreign-DOM-mutation rule (authenticated source frame only; wrapper +only when both dimensions ≤ 1 px; anchor-ad and fixed/sticky guards). **G4d — Notifications.** APS carries neither `nurl` nor `burl` -(`aps.rs:839`); excluded entirely. For carrying paths: bind per flow — PUC: -owned, slot-and-ad-id-matched bridge claim; direct: validated render start -(server must preserve + macro-expand `nurl`/`burl` in `/auction` -responses — `formats.rs:423` omits them — and the client must parse and -https-validate them — `core/auction.ts:43` drops them); fallback: -attributed `gam_empty` immediately before render. `nurl` at bind, `burl` -at `accepted`. **Economic identity is the normalized pair -`(id_kind, id_value)`** (direct attempts without `hb_adid` use -`bid_id`), idempotency key -`(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`. **Every -dispatch emits `notification_sent{kind: nurl|burl, id_key_hash, result: -queued | failed}`** where `id_key_hash` is a 16-hex truncated HMAC of the -idempotency key — making the duplicate-`burl` invariant observable in -production (the gates table queries zero duplicates per key hash); external -billing reconciliation remains the authoritative backstop. No retries. - -**G4e — Fallback.** Opt-in; renders only on a terminal `gam_empty` -unambiguously attributed to a TS cycle; ownership does not gate; -publisher-initiated or unattributable never triggers; timeouts never -render. +(`aps.rs:839`; the AAX envelope excludes them; the integration guide +documents no generic APS beacons) — excluded entirely; the Amazon runner +lifecycle is unchanged. For carrying paths (PBS and other OpenRTB +providers): + +- Bind per flow, never selection or targeting (`ad_init.test.ts:1824`): + PUC — an owned, slot-and-ad-id-matched bridge claim; direct — + validated render start (the server must preserve and macro-expand + `nurl`/`burl` in `/auction` responses, `formats.rs:423` omits them; + the client must parse and https-validate them, `core/auction.ts:43` + drops them); fallback — attributed `gam_empty` immediately before + fallback render. +- `nurl` at bind; `burl` at `accepted`; **no retries**; idempotency key + `(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)` with the + normalized economic identity `(id_kind, id_value)` (direct attempts + without `hb_adid` use `bid_id`). +- **Observability without client cryptography:** the server mints an + opaque **`notif_id`** (12-char token, same generator as G2) per + notification-carrying bid and delivers it with the bid; every dispatch + emits `notification_sent{kind: nurl | burl, notif_id, result: +queued | failed}`. The browser computes no hashes (a client-held HMAC + key would break the pseudonymization boundary; a rotating token would + break stability across a gate window). +- **The duplicate-`burl` invariant is proven hermetically and + reconciled externally, not "proven" by lossy telemetry:** hermetic + tests pin exactly-once dispatch logic; production + `notification_sent` duplicates are a **detection alarm** (any + observed duplicate is a red gate); absence-of-duplicates is + established by billing reconciliation (GAM/SSP reports vs server-side + win counts) because sampled, best-effort telemetry cannot prove a + zero. + +**G4e — Fallback.** Opt-in +(`[auction].client_render_fallback = "renderer"`); renders only after a +terminal `gam_empty` unambiguously attributed to a TS cycle; ownership +does not gate it; publisher-initiated or unattributable cycles never +trigger it; timeouts never render. **G4f — Direct `/auction` lifecycle.** `RenderAttempt` keyed -`(trace_id, nav_gen, refresh_gen, slot)`; per-slot latest-wins with -cancellation; generation checks before every DOM/beacon effect -(`request.ts:31` races today); G4b sequence 3/4; G4d direct binds; -disposal on navigation; single terminal state; -`tsjs.requestAds(options): Promise` with +`(trace_id, nav_gen, refresh_gen, slot)`; per-slot **latest-wins with +cancellation** (concurrent calls cancel the older attempt; +`request.ts:31` races today); generation checks before every DOM/beacon +effect; G4b sequences 3/4; G4d direct binds; navigation disposal; one +terminal state. **The auction client returns a discriminated result** — +`{ok: bids[]} | {error: "auction_timeout" | "network_error" | +"http_error" | "invalid_response"}` — replacing the baseline's +everything-is-an-empty-array collapse (§3.14); only a successfully +parsed response with no winner maps to `no_bid`. Public API: +`tsjs.requestAds(options): Promise`, `RequestAdsResult = {traceId, slots: [{slot, outcome: "rendered" | -"no_bid" | "failed" | "cancelled", reason?}]}`. - -**G4g — Mid-attempt configuration.** An attempt **snapshots its -configuration at creation**. The in-pool emergency kill switch cancels -attempts that have not yet passed their commit point — defined as -`bridge_response_sent` (PUC flows) or first DOM insertion (direct/fallback -flows); attempts past commit run to their terminal state; dispatched -notifications are never recalled. +"no_bid" | "failed" | "cancelled", reason?}]}`, settling when every slot +attempt is terminal. Reversed-response tests required. +**Fallback identity:** fallback is a **child attempt** — new +`RenderAttempt`, `flow = fallback`, carrying `parent_flow` (the +originating flow); the terminal `gam_empty` belongs to the parent +attempt under the parent's flow; the canonical view links parent and +child on `(trace_id, nav_gen, slot, refresh_gen)`. + +**G4g — Mid-attempt configuration and the commit point.** An attempt +snapshots configuration at creation. **Commit = the earliest +irreversible action** — the first of: notification dispatch (`nurl` at +bind), `bridge_response_sent`, or first DOM insertion. The +generation/kill-switch check runs **immediately before each** of those; +an attempt past commit runs to its terminal state; dispatched +notifications are never recalled. (Revision 7 put commit after the +`nurl` side effect; that ordering error is corrected.) ### G5 — Deployment contracts -- Config `format_version`; release-time config (§0). -- **Assets pre-materialized at release publication:** because config ships - with the release, the validated module vectors are known when the release - is built — concatenated bytes + hashes are produced then and embedded; - serving is lookup-only on every adapter (Fastly's per-request state, - §3.13, makes construction-time caching meaningless there — the previous - revision's claim is corrected). The §7.10 server benchmark measures the - lookup path and guards against regression to per-request concatenation. - Unknown vector = release-build error; unknown hash = `410`, `no-store`; - exact match = `public, max-age=31536000, immutable`. -- **Internal route families — now four:** renderer, client-events, - CSP-report, **and `/_ts/trace-auth`** — dispatch before auth/EC/ - publisher/integration filters; all methods reserved locally (405 + - `Allow` + `no-store`; unknown versions 404 `no-store`; never publisher - fall-through); no body/cookie/authorization forwarding; normalized - scheme+host+port origin comparison; each family rate-limited (§5.4) and - covered by four-adapter parity tests. +- Config `format_version` + manifest hash verification (§0). +- **Assets pre-materialized at release publication:** config is a + release-time input, so validated module vectors are known when the + release is built; concatenated bytes + hashes are produced then and + embedded; serving is lookup-only on every adapter (Fastly is + per-request, §3.13, so construction-time caching would be + meaningless). Unknown vector = release-build error; unknown hash = + `410 no-store`; exact match = `public, max-age=31536000, immutable`. +- **Internal route families — four:** renderer, client-events, + CSP-report, `/_ts/trace-auth`. All dispatch before auth/EC/publisher/ + integration filters (Fastly today runs EC setup and pre-route filters + first, `app.rs:709`); all methods and version prefixes reserved + locally (405 + `Allow` + `no-store`; unknown version 404 `no-store`; + never the publisher fall-through of `adapter-spin app.rs:804`); no + body/cookie/authorization forwarding. **Origin policy is per family**, + not universal: client-events and trace-auth require strict normalized + same-origin (scheme+host+port); the CSP route admits opaque/`null` + origins and authenticates by server-selected path identity plus abuse + limits; the renderer document is a public GET validated by + version/path only (it is loaded from sandboxed opaque contexts — + browser-origin authentication is impossible there by design). +- Ingest routes exist in all four adapters; Fastly has real sinks; + others accept-count-drop by contract (DR-5). - §5.6 schemas deploy and validate before writers enable. ## 5. Observability -### 5.1 Wire payload and field matrix +### 5.1 Wire payload and per-event field matrix ``` { v: 1, traces: [ { trace_id, auth, events: [ { nav_gen, refresh_gen, seq, flow, auction_id?, t, ...fields } ] } ] } ``` -| `t` | fields (absent = absent on wire, NULL in storage) | -| -------------------------- | ------------------------------------------------- | -| `bid_received` | slot, id_kind, source | -| `targeting_set` | slot, id_kind | -| `bridge_request` | slot, id_kind, matched | -| `bridge_response_sent` | slot, source | -| `render_attempt` | slot, source | -| `render_accepted` | slot, source | -| `render_fail` | slot, reason, source? | -| `gam_nonempty` | slot | -| `gam_empty` | slot | -| `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? | -| `renderer_document_loaded` | slot | -| `runner_loaded` | slot | -| `runner_failed` | slot, reason | -| `adm_document_loaded` | slot | -| `fallback_start` | slot | -| `billing_outcome` | slot, outcome (`billed_then_failed`) | -| `notification_sent` | slot, kind (`nurl`\|`burl`), id_key_hash, result | -| `client_queue_overflow` | dropped (count) | -| `heartbeat` | probe_id, expected_seq | - -`source` is **nullable on `render_fail`**: absent for pre-source reasons +| `t` | fields | allowed `flow` | +| -------------------------- | --------------------------------------------- | ----------------------- | +| `bid_received` | slot, id_kind, source | render flows | +| `targeting_set` | slot, id_kind | render flows | +| `bridge_request` | slot, id_kind, matched | ssat, prebid, page_bids | +| `bridge_response_sent` | slot, source | ssat, prebid, page_bids | +| `render_attempt` | slot, source | render flows | +| `render_accepted` | slot, source | render flows | +| `render_fail` | slot, reason, source? | render flows | +| `gam_nonempty` | slot | ssat, prebid, page_bids | +| `gam_empty` | slot | ssat, prebid, page_bids | +| `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? | ssat, prebid, page_bids | +| `renderer_document_loaded` | slot | render flows | +| `runner_loaded` | slot | render flows | +| `runner_failed` | slot, reason | render flows | +| `adm_document_loaded` | slot | render flows | +| `fallback_start` | slot, parent_flow | fallback | +| `notification_sent` | slot, kind (`nurl`\|`burl`), notif_id, result | render flows | +| `client_queue_overflow` | dropped (count) | system | +| `heartbeat` | probe_id, expected_seq | system | + +"Render flows" = `ssat | prebid | page_bids | direct | fallback`. +`source` on `render_fail` is **nullable**: absent for pre-source reasons (`gpt_absent`, `pbjs_absent`, `slot_unresolved`, `intent_no_request`, -`abi_mismatch`, `registry_full`, `bundle_partial`); required for -source-specific reasons — the per-reason validity matrix is part of the -generated schema. Reason enum as revision 6 plus `currency_mismatch`. -**Heartbeats** are sent by identified probes under mode `probe` (§5.3): -excluded from every product metric by mode, never sampled out, and the -canonical freshness/loss query counts `expected_seq` gaps. +`abi_mismatch`, `registry_full`, `bundle_partial`); required otherwise. +The per-event/per-reason validity matrix is a generated artifact (§6.7). +Reason enum (closed): `renderer_document_no_load`, `runner_no_load`, +`runner_failed`, `descriptor_invalid`, `invalid_dimensions`, +`dimensions_out_of_range`, `bridge_id_mismatch`, `cycle_unattributable`, +`intent_no_request`, `stale_navigation`, `bridge_claim_timeout`, +`gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, +`pbjs_absent`, `bundle_partial`, `fallback_cancelled`, `abi_mismatch`, +`registry_full`, `currency_mismatch`, `auction_timeout`, +`network_error`, `http_error`, `invalid_response`, +`adm_document_no_load`. No client timestamp; the server stamps +`received_at`; ordering within a trace is `seq`. ### 5.2 Transport and overflow -`fetch keepalive credentials:"omit"` primary; `sendBeacon(url, -new Blob([json], {type: "application/json"}))` on `pagehide`. Queue bound 256. **Overflow never enqueues into the full queue**: an out-of-band -saturating counter accumulates drops, and one coalesced -`client_queue_overflow{dropped}` is materialized into the **next flush**. +`fetch(..., {keepalive: true, credentials: "same-origin"})` primary (§0 +affinity; the handler still derives no identity from cookies); +`pagehide` fallback `navigator.sendBeacon(url, new Blob([json], {type: +"application/json"}))`. Flush every 5 s and on +`visibilitychange`/`pagehide`. Queue bound 256 events. **Overflow never +enqueues into the full queue:** an out-of-band saturating counter +accumulates drops and one coalesced `client_queue_overflow{dropped}` is +materialized into the next flush. ### 5.3 Signed trace authorization -`v1....`; `auth` ingest bound 256 bytes; kid -`^[a-z0-9-]{1,16}$`; keys ≥ 256-bit CSPRNG in the secret store, previous -keys retained ≥ 24 h; canonical decimal `exp`, ±60 s skew, ≤ 15 min future; -`sig` = unpadded base64url HMAC-SHA-256 over the domain-separated -length-prefixed input (revision 6's exact encoding); constant-time compare; -per-group rejection. - -- **Modes:** `sampled | unsampled | diagnostic | probe`. `unsampled` - transmits nothing and is rejected at ingest if carried. `probe` is - issued only to synthetic monitors (server-side issuance to the probe - runner) and marks heartbeat traffic. -- **Renewal preserves mode by verification, not trust:** `GET -/_ts/trace-auth` presents the **current signed authorization** in - `X-TSJS-Trace-Auth` (plus the trace header); the server verifies the - still-valid token and re-signs the **same trace_id and mode** with fresh - `exp`. The trace id itself carries no mode, and no adapter may rely on - cross-request state (§3.13) — the presented token is the state. Renewal - after expiry fails; the client stops transmitting and counts locally. -- **Diagnostic credential — complete protocol:** issuance `POST +Format `v1....`; the `auth` field has its own +ingest bound of **256 bytes** (every other string keeps the 64-char +cap — a 43-char unpadded-base64url signature cannot fit 64 with its +prefix fields). + +- `kid`: `^[a-z0-9-]{1,16}$`; active + previous keys in the platform + secret store; keys ≥ 256-bit CSPRNG; previous keys retained ≥ 24 h + (≫ max token lifetime + skew). Missing key with the feature enabled → + startup/first-use failure, never silent. +- `exp`: canonical decimal unix seconds (no sign, no leading zeros); + ±60 s skew; ≤ 15 min future. +- `mode`: `sampled | unsampled | diagnostic | probe`. **`unsampled` is + the signed discard decision:** the client neither enqueues nor + transmits for it, and ingest rejects any group carrying it. `probe` + marks synthetic monitors (server-issued to probe runners); probe + traffic is never sampled out and is excluded from product metrics by + mode. +- `sig`: unpadded base64url of HMAC-SHA-256 (43 chars) over the + domain-separated, length-prefixed input `"ts-trace-auth-v1" || +u32be(len(origin)) || origin || u32be(len(trace_id)) || trace_id || +u32be(len(mode)) || mode || u64be(exp)`, strings UTF-8, `origin` = + externally visible scheme+host+port. Constant-time comparison. +- **Renewal preserves mode by verification, not trust:** + `GET /_ts/trace-auth` presents the current still-valid token in + `X-TSJS-Trace-Auth` (plus the trace header); the server verifies and + re-signs the same `trace_id` and `mode` with fresh `exp`. **The only + mode transition that exists is the diagnostic upgrade, a distinct + operation:** `POST /_ts/trace-auth/upgrade` presenting the current + token **and** a valid diagnostic credential; it re-signs with + `mode = diagnostic` and `exp = min(now + 15 min, credential expiry)`. + Plain renewal never changes mode. Renewal after expiry fails; the + client stops transmitting and counts locally. +- **Diagnostic credential:** issued `POST /_ts/admin/diagnostic-credential` under the existing admin - authentication (CSRF: same-origin + custom header required), response - `{credential}` where credential = `d1....`, - absolute expiry ≤ 60 min, HMAC over the publisher origin + expiry, - **replayable short-lived bearer by design** (bounded by expiry and - origin binding; not one-time — stated, not implied). Transport to the - page: the operator opens the page with a `#tsdiag=` fragment - (never sent to any server in a URL); the client stores it in - `sessionStorage` and presents it in `X-TSJS-Diag` on trace-auth, - page-bids, and `/auction` requests. The server, seeing a valid - credential, issues/renews the trace authorization with - `mode = diagnostic` and **`exp = min(now + 15 min, -credential expiry)`** — a trace authorization never outlives the - credential. Initial HTML cannot see the fragment, so `nav_gen 0` starts - `sampled|unsampled` and the client immediately upgrades via trace-auth. - Validation is stateless HMAC — all four adapters support it. Forgery, - replay-past-expiry, and wrong-origin tests required. The tester cookie - remains content-only (§2.5). -- **Lazy cached initialization applies to every secret-backed component** - (trace-auth keys, diagnostic keys, sampling key, sinks): first-use - resolution with a cached result on request-bound platforms; resolution - failure with the feature enabled → the feature's startup/first-use error - path, never silent. + authentication (CSRF: same-origin + custom header), format + `d1....`, absolute expiry ≤ 60 min, + origin-bound, **replayable short-lived bearer by design** (bounded by + expiry + origin binding; stated, not implied). **Exposure-minimized + transport:** the operator opens the page with `#tsdiag=`; + the synchronous bootstrap reads it, **immediately clears the fragment + via `history.replaceState`**, holds the credential **in memory only** + (RuntimeSession — never `sessionStorage`, which page scripts can + read), and exchanges it via the upgrade operation as soon as the trace + exists. Because initial HTML cannot see the fragment, `nav_gen 0` + starts `sampled | unsampled`; **when a pending `#tsdiag` fragment is + detected, the client buffers events locally without transmission + (bounded 256) until the upgrade resolves**, then flushes under + diagnostic mode — one-page-load diagnostic completeness holds without + delaying rendering. Forgery, wrong-origin, and replay-past-expiry + tests required. Validation is stateless HMAC — all four adapters. +- **Lazy cached initialization** applies to every secret-backed + component (trace-auth keys, diagnostic keys, sampling key, sinks): + first-use resolution with a cached result on request-bound platforms; + failure with the feature enabled is that feature's loud error path. ### 5.4 Ingest and rate limiting -As revision 6 (limits, same-origin, fail-closed drops), with the limiter -contract completed: key namespace per route family; portable maps hold -≤ 65,536 (Axum) / 4,096 (Cloudflare, Spin per-instance) entries with -10-minute entry TTL, cleanup on access plus periodic sweep — **capacity -pressure rejects unseen identities but expired entries are always -reclaimable, so saturation is bounded, not permanent**; missing client -address → a shared `unknown` bucket at 1 request/min; Fastly uses the -platform 60 s window counter at limit 20 with documented overshoot ≤ 2× -under concurrent bursts (`rate_limiter.rs:40` is read-then-increment); -Axum XFF selection = rightmost entry after skipping exactly -`trusted_proxy_hops`. `/_ts/trace-auth` and `/_ts/csp-reports/` -get their own buckets (10/min, burst 20 intent). - -### 5.5 Sink, canonical views, monitoring - -Stable event key `(publisher_domain, trace_id, seq)`; canonical views -`ts_client_events_v` (dedup) and `ts_render_attempts_v` (attempt grain, -joined on the G1 key including `auction_id`); dashboards/alerts query views -only. Datasource-side monitoring via `heartbeat` events from identified -probes (mode `probe`); freshness = heartbeat lag ≤ 5 min, loss = -`expected_seq` gaps < 0.1%; alert owner: release owner's on-call. +- `POST /_ts/client-events`: `application/json` only; no + `Content-Encoding`; responds `204`, `no-store`; never echoes input. + Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars + (`auth` ≤ 256 bytes); `trace_id ^[0-9a-f]{32}$`; integers `[0, 2³¹)`; + width/height `[0, 8192]`. Violations → drop-and-count with `204`. +- Same-origin (client-events, trace-auth): `Sec-Fetch-Site: +same-origin` when present, else normalized `Origin` equality; absent + both → drop-and-count. +- **Rate limiting — adapter abstraction with declared semantics:** + trait `ClientEventLimiter`, key namespace per route family; intent + 10 req/min, burst 20 per client address. Axum: real in-process token + bucket, map ≤ 65,536 entries; Cloudflare/Spin: per-isolate/instance + best-effort, ≤ 4,096 entries; entry TTL 10 min with cleanup on access + plus periodic sweep — **capacity pressure rejects unseen identities, + but expired entries are always reclaimable, so saturation is bounded, + not permanent**; missing client address → shared `unknown` bucket at + 1 req/min. Fastly: the platform 60 s fixed-window counter at limit 20 + as a documented approximation; because its lookup and increment are + separate operations (§3.13), **overshoot under a synchronized burst + is bounded only by in-flight concurrency, and no numeric multiple is + claimed** — the synchronized-burst test (> 40 concurrent) documents + observed behavior, and a penalty-box follow-up is recorded if + observed overshoot is operationally unacceptable. Limiter + unavailable/errored → drop early with `204`. Trusted client address: + Fastly platform client IP; Axum rightmost `X-Forwarded-For` entry + after skipping exactly `trusted_proxy_hops` (absent config → socket + peer only); Cloudflare `CF-Connecting-IP`; Spin platform address. + `/_ts/trace-auth` and `/_ts/csp-reports/` carry their own + buckets with the same intent. + +### 5.5 Sinks, canonical views, per-sink monitoring + +- Stable event key `(publisher_domain, trace_id, seq)`. Canonical views: + `ts_client_events_v` (dedup: latest `received_at` per key) and + `ts_render_attempts_v` (attempt grain per G1). Dashboards and alerts + query canonical views only; raw-to-raw joins are forbidden (row + multiplication). +- The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`) + and cannot see downstream rejection — **each datasource gets its own + synthetic probe and freshness/loss query, per adapter write path**: + client-events via `heartbeat` events (mode `probe`, + `expected_seq` gaps = loss, lag = freshness); CSP via probe reports + to a reserved `policy_id = probe`; ops via a probe counter. A green + client-events heartbeat says nothing about the CSP or ops + credentials — hence three probes. Alert owner: release owner's + on-call. ### 5.6 Physical schemas (deployed before writers) -- **`ts_client_events`**: revision 6 columns plus `flow Enum`, - `auction_id Nullable(FixedString(36))`, `action Nullable(Enum)`, - `kind Nullable(Enum)`, `id_key_hash Nullable(FixedString(16))`, - `result Nullable(Enum)`, `probe_id Nullable(String)`, - `expected_seq Nullable(UInt32)`; `mode Enum(sampled|diagnostic|probe)`. -- **Auction rows**: nullable `trace_id`, `mode`, plus **`release_id`** - (the server-side cohort discriminator). Two added row types with full - physical definitions: - - `bid_drop {provider LowCardinality(String), slot Nullable(String), -reason Enum(AuctionDropReason), width Nullable(UInt16), height -Nullable(UInt16), count UInt32}` — cap 32 rows/auction **plus** one - overflow row (`reason = overflow`, `count` = dropped-row count; the - overflow row is not counted against the cap); - - `selection_summary {slot String, winner_source -Enum(mediator|direct|none), winner_provider Nullable(String) — NULL -exactly when winner_source = none, candidates_direct UInt16, -candidates_mediator UInt16, dedup_hits UInt16, currency_rejected -UInt16, provenance_invalid UInt16, mediator_superseded UInt16}` — cap - 8 rows/auction plus one **auction-level totals row** (slot = - `_totals`) that always survives truncation, so gate denominators never - depend on per-slot rows. Counters are saturating UInt with - `0xFFFF`/`0xFFFFFFFF` as the saturation sentinel. - - **`AuctionDropReason` (closed, server-side):** `script_rendering_ -disabled, invalid_dimensions, dimensions_out_of_range, -missing_render_source, invalid_creative_url, unsupported_tagtype, +- **`ts_client_events`**: `received_at DateTime64, publisher_domain +LowCardinality(String), release_id String, trace_id FixedString(32), +mode Enum(sampled|diagnostic|probe), nav_gen UInt32, refresh_gen +UInt32, seq UInt32, flow Enum(ssat|prebid|page_bids|direct|fallback| +system), auction_id Nullable(FixedString(36)), event Enum(§5.1), slot +Nullable(String), id_kind Nullable(Enum), matched Nullable(UInt8), +source Nullable(Enum), reason Nullable(Enum), action Nullable(Enum), +parent_flow Nullable(Enum), kind Nullable(Enum), notif_id +Nullable(FixedString(12)), result Nullable(Enum), dropped +Nullable(UInt32), probe_id Nullable(String), expected_seq +Nullable(UInt32)`. Sorting key `(publisher_domain, received_at, +trace_id, seq)`; TTL 30 days; own ingest token; sink batch cap 512; + startup validation of dataset + token when enabled; + sink-unavailable → accept-count-drop. +- **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`): + add nullable `trace_id`, `mode`, `release_id`. Two added row types + with an explicit **`row_kind Enum(slot | totals | overflow)`** so + totals/overflow rows are valid instances (no publisher-controlled + sentinel strings; inapplicable fields nullable): + - `bid_drop {row_kind, provider Nullable(LowCardinality(String)) — +NULL on overflow, slot Nullable(String), reason +Enum(AuctionDropReason), width Nullable(UInt16), height +Nullable(UInt16), count UInt32}` — cap 32 slot-rows/auction plus one + overflow row whose `count` = **actual dropped bids**, not compacted + rows; + - `selection_summary {row_kind, slot Nullable(String) — NULL on +totals, winner_source Nullable(Enum(mediator|direct|none)) — NULL on +totals, winner_provider Nullable(String) — NULL when winner_source +≠ a winner, candidates_direct UInt16, candidates_mediator UInt16, +dedup_hits UInt16, currency_rejected UInt16, provenance_invalid +UInt16, mediator_superseded UInt16}` — cap 8 slot-rows plus one + totals row that always survives truncation. Counters saturate at + `0xFFFF`/`0xFFFFFFFF`. + - **`AuctionDropReason` (closed, exhaustive over baseline + producers):** `script_rendering_disabled, invalid_dimensions, +dimensions_out_of_range, missing_render_source, +invalid_creative_url, unsupported_tagtype, render_payload_too_large, unexpected_response_shape, currency_mismatch, floor_rejected, provenance_invalid, -duplicate_demand, overflow`. -- **`ts_csp_reports`** (aggregate only): `{received_at, release_id, -policy_id, cohort, directive_bucket Enum, source_bucket Enum, count}`; - 30-day TTL. **CSP ingest contract:** pre-buffer body ≤ 8 KiB, ≤ 10 - reports/request, strings ≤ 256, nesting ≤ 4, no `Content-Encoding`, own - limiter bucket, fire-and-forget dispatch covered by probe heartbeats. -- **`ts_ops_counters`**: `{received_at, release_id, counter Enum, value -UInt64}` — the physical home for renderer-route counters (requests, - unknown-version, auth-blocked) and limiter/abuse counters. -- **Settings, constructible:** `[telemetry.client_events]` - `collection_enabled`, `sink_enabled` (collection without a sink = - accept-count-drop by configuration, not accident), `sample_rate`, - `api_host`, `dataset`, `token_secret`, `secret_store`, - `max_body_bytes`; `[telemetry.trace_auth]` `secret_store`, - `active_kid`, `previous_kids`, `sampling_key_secret`; - `[telemetry.diagnostic]` `secret_store`, `active_kid`. - `RuntimeServices` gains the client-events sink handle. -- APS parsing returns structured drop observations (slot + dimensions); - > 8192 → `dimensions_out_of_range`, dimensions omitted. +duplicate_demand, missing_bid_id, duplicate_bid_id, unknown_impid, +invalid_price, unsupported_media_type, creative_id_too_large, +empty_seatbid, renderer_extension_serialization_failed, +no_render_source, lost_to_higher_bid, overflow` — covering the + outcomes emitted at `aps.rs:740-929` and `formats.rs:408-419`; a + **compile-time exhaustiveness test maps every producer to the + enum**. +- **`ts_csp_reports`**: `received_at DateTime64, publisher_domain +LowCardinality(String), release_id String, policy_id +LowCardinality(String), cohort LowCardinality(String), +directive_bucket Enum(script|style|frame|img|connect|font|media| +worker|other), source_bucket Enum(https_host_allowlisted|data|blob| +inline|eval|other), count UInt32`; sorting key `(publisher_domain, +received_at, policy_id)`; TTL 30 days; settings + `[telemetry.csp_reports] enabled, api_host, dataset, token_secret, +secret_store`. Ingest: pre-buffer body ≤ 8 KiB, ≤ 10 reports/request, + strings ≤ 256, nesting ≤ 4, both media types + (`application/csp-report`, `application/reports+json`) with separate + validators, unused fields discarded before logging, own limiter + bucket. +- **`ts_ops_counters`**: `received_at DateTime64, publisher_domain +LowCardinality(String), release_id String, counter +Enum(renderer_requests|renderer_unknown_version|renderer_auth_blocked| +ingest_accepted|ingest_dropped|ingest_rate_limited|abuse_flagged| +probe), value UInt64`; sorting key `(publisher_domain, received_at, +counter)`; TTL 90 days; settings `[telemetry.ops_counters]` (same + shape). +- **Sink plumbing:** one generic multi-target Tinybird sink trait; the + `RuntimeServices` (`platform/types.rs:158`) gains handles for + client-events, CSP, and ops targets beside the auction sink; each + target has its own dataset + token settings as above. +- **Settings:** `[telemetry.client_events] collection_enabled, +sink_enabled, sample_rate, api_host, dataset, token_secret, +secret_store, max_body_bytes`; `[telemetry.trace_auth] secret_store, +active_kid, previous_kids, sampling_key_secret`; + `[telemetry.diagnostic] secret_store, active_kid`. +- APS parsing returns structured drop observations + `{reason, slot, width?, height?}` (`aps.rs:722` loses slot/values + today); >8192 → `dimensions_out_of_range` with dimensions omitted. ### 5.7 Modes and SLIs Production (sink-backed): deterministic keyed sampling (default 0.10). -SLIs: pipeline availability (heartbeat freshness/loss); failure detection -(≥ 1% of sampled render attempts visible within one hour at ≥ 10,000 -sampled attempts/hour). Diagnostic: credential-gated, unsampled, full -stream + console mirroring + debug envelopes (§2.5). +SLIs: **pipeline availability** — per-sink probe freshness ≤ 5 min and +probe loss < 0.1% (fails during sink outages, alarmed); **failure +detection** — a failure mode affecting ≥ 1% of sampled render attempts +visible within one hour, at ≥ 10,000 sampled attempts/hour. Diagnostic: +credential-gated, unsampled, full stream + console mirroring + debug +envelopes (§2.5). ### 5.8 Server-side drop surfacing -As revision 6, with `selection_summary`/`bid_drop` physicalized above. +Bounded structured summary whenever any bid is dropped; `bid_drop` and +`selection_summary` rows (§5.6); the initial-HTML `ts-debug` comment +carries the drop summary; page-bids and `/auction` carry the +tester-gated structured `debug` field. Startup warnings: APS + +`allow_script_creatives = false`; mediator + direct providers without +an explicit `winner_selection` (§6.1 hard error). ## 6. APS delivery fixes -### 6.1 Mediation — total order, closed contradictions - -1. **Currency.** Required `[auction].currency` (ISO 4217). Providers - validate response currency at parse; providers with a contract-implied - currency validate that implication — **APS enabled with a non-USD - configured currency is a startup error** (`aps.rs:475` stamps USD), not - a silent all-drop. Prebid's parse must validate rather than assume USD - (`prebid.rs:2433`). Mismatch → `bid_drop{currency_mismatch}`. -2. **Candidate identity, total by construction.** `source_candidate_id` = - `(provider_name, upstream_bid_id)` where the upstream id is **required - bounded (≤ 64 chars) and unique per provider response at admission**; - a bid missing an id, or duplicating one, receives a deterministic - **intrinsic fingerprint**: `fp = hex(HMAC(auction_id, provider || slot -|| price_micros || render_source_digest))` — identical fingerprints are - the same demand and dedup to one candidate. `candidate_id` (wire echo) - is CSPRNG with in-auction collision retry and is **never** an ordering - key. +### 6.1 Mediation — total order, no fictional identities + +Baseline defects: no forwarded candidate id; lossy last-write-wins +`(provider, slot, bidder)` field restoration (`adserver_mock.rs:95`); +arrival-order equal-price ties (`orchestrator.rs:827`); Prebid assumes +USD (`prebid.rs:2433`); APS stamps USD (`aps.rs:475`); no configured +currency. Replacement, identical in the synchronous and split +dispatch/collect paths via one shared helper: + +1. **Currency.** Required `[auction].currency` (ISO 4217). Every + provider parse validates its response currency; contract-implied + currencies are validated as implied — **APS enabled with a non-USD + configured currency is a startup error**, not a silent all-drop. + Mismatch → `bid_drop{currency_mismatch}`. +2. **Candidate identity.** `source_candidate_id` = + `(provider_name, upstream_bid_id)`; the upstream id is **required, + ≤ 64 chars, and unique per provider response — bids missing an id or + duplicating one are rejected** (`bid_drop{missing_bid_id | +duplicate_bid_id}`). No fingerprint fallback: a fingerprint over a + partial field set can merge economically distinct demand, and + OpenRTB requires bid ids — rejection is honest. `candidate_id` (wire + echo) is CSPRNG with in-auction collision retry and is never an + ordering key. Mediator-native bids get identities the same way from + the mediator's response. 3. **Mediator exchange.** Forwarded candidates carry - `ext.trusted_server.candidate_id`; the mediator echoes it. Resolution - rules, stated exhaustively: an echoed id that resolves → the forwarded - candidate, provenance `mediator`; **price is authoritative from the - mediator; every render-source and notification field comes from the - stored candidate; deal fields are out of scope entirely** (no deal - identity exists in the model — revision 6's "deal fields from mediator" - is deleted). A mediator bid whose **any** render-source field differs - from the stored candidate is reclassified mediator-native. An echoed id - that does **not** resolve → that bid is discarded and counted - (`provenance_invalid`); **mediator-native bids and direct candidates - for the slot all remain eligible** — "fails closed" applies to the - invalid claim, not the slot. -4. Floors filter both populations; echoed candidates remove their direct - twins. -5. **Selection order (total):** decoded CPM desc → provenance rank - (mediator first) → `source_candidate_id` asc (fingerprints compare as - their hex strings). Arrival order can never matter. -6. **Strategy** required when mediator + direct providers coexist: - `mediator_only` (timeout → no winners unless - `mediator_timeout_fallback = "direct"`) or `merge_highest_cpm` - (timeout → direct-only, reported). -7. Reporting: `selection_summary` rows + `_totals` (§5.6). - -### 6.2–6.5 - -As revision 6: exact dimensions with structured drops; script creatives -default-off but loud — **DR-2's output is a deployment decision** (enable -with explicit sandbox/security approval, or accept a quantified maximum -excluded-demand share and gate Phase 3 on it), not a documentation -priority; G2 identity; G4e fallback. + `ext.trusted_server.candidate_id`; the mediator echoes it (contract + for every mediator, `adserver_mock` included). Echoed id resolves → + the forwarded candidate, provenance `mediator`: **price is + authoritative from the mediator; every render-source and + notification field comes from the stored candidate; deal fields are + out of scope entirely** (no deal identity exists in the model). A + mediator bid whose any render-source field differs from the stored + candidate is reclassified mediator-native. An unresolvable echoed id + → that bid is discarded and counted (`provenance_invalid`); + mediator-native bids **and direct candidates remain eligible** — + fail-closed applies to the invalid claim, not the slot. +4. Floors filter both populations; echoed candidates remove their + direct twins (`dedup_hits`). +5. **Selection order (total, intrinsic):** decoded CPM desc → + provenance rank (mediator first) → `source_candidate_id` asc. + Arrival order can never matter. +6. **Strategy** required when mediator + direct providers coexist + (startup error if absent): `mediator_only` (timeout → no winners + unless `mediator_timeout_fallback = "direct"`) or + `merge_highest_cpm` (timeout → direct-only, reported). +7. Reporting: `selection_summary` slot rows + totals row (§5.6). + +### 6.2 Dimensions + +Exact size membership stays (`aps.rs:675`). The fix is visibility +(`bid_drop{invalid_dimensions, w, h}`) plus documentation ("request the +sizes you accept"); accepting unrequested sizes would conceal an +upstream protocol violation. + +### 6.3 Script creatives + +`allow_script_creatives` stays default-`false`; the consequence is loud +(§5.8). **DR-2's output is a deployment decision:** enable with explicit +sandbox/security approval, or accept a quantified maximum +excluded-demand share and gate Phase 3 on it. + +### 6.4 Render identity + +As specified in G2 (token format, registry-union capacity, tombstones, +cache-path byte identity). + +### 6.5 Fallback + +As specified in G4e/G4a/G4f (attribution-gated child attempt; awaitable +renderer conversion precedes it; timeouts never render). ### 6.6 Renderer endpoint -As revision 6 (unconditional route, versioned immutable document with a -checked-in per-version header manifest, three-message ack, aggregate route -counters now physically in `ts_ops_counters`, CSP rollout with -server-selected `policy_id` report paths and closed buckets — ingest -bounds per §5.6). +- The static renderer document route registers **unconditionally in + every adapter** (the APS provider stays config-gated); startup + validation fails if an auth handler pattern covers it; §G5 isolation + rules apply. +- Path `/integrations/aps/renderer/v1`, embedded, served + `Cache-Control: public, max-age=31536000, immutable`; canary versions + `no-store` (or bounded below cohort lifetime); a **checked-in header + manifest per renderer version** freezes headers (CSP included) with + the bytes — a version's headers never change after publication. + Unknown versions → 404 `no-store`. +- Three-message acknowledgement per G4b sequence 1. +- Server route counters are aggregate (`ts_ops_counters`) — the nonce + rides the URL fragment and never reaches the server. +- **CSP rollout, three instruments:** discovery on the **currently + enforced** policy with reporting attached (report-only alone cannot + reveal what the enforced policy already blocks); **tightening** via + report-only; **relaxation** via a small enforced cohort on a + short-lived canary version, gated on runner acceptance rate, CSP + violation rate, and render-failure rate, with a kill switch; once + frozen, a new immutable `/v2` ships. **CSP reports are advisory** — + for opaque-origin reports the body-supplied document URL and policy + version are forgeable, so policy identity is encoded in the + **server-selected report path** (`/_ts/csp-reports/`); + bucketed aggregation only (§5.6 buckets, global and per-cohort caps); + never a sole automatic rollback signal. Browser capture on Chromium, + Firefox, and WebKit (CI is Chromium-only today, + `playwright.config.ts:16`; the matrix extends for this suite). ### 6.7 One descriptor schema -As revision 6 (generated JSON-Schema + TS parser + ES5 inline fragment + -fixtures; semantic validators handwritten; outer tolerance only; the -per-reason `source` validity matrix of §5.1 joins the generated artifacts). +Wire truth is the tagged `BidRenderer` envelope (discriminator on the +enum, `types.rs:188-211`). A wire-schema crate/xtask (separate from +`trusted-server-js` — core already depends on it, `Cargo.toml:45`, so +the reverse edge would cycle) generates: the JSON-Schema artifact, the +TS structural parser, the ES5 inline validator fragment, the §5.1 +per-event/per-reason validity matrix, and shared fixtures — checked in, +staleness-gated. Semantic checks stay handwritten on both sides +(URL/origin policy, canonical base64, length bounds, the exact one-bid +AAX projection, cross-field equality). Unknown-field tolerance applies +only to the outer versioned descriptor; the decoded AAX envelope +remains an exact projection. A shared positive + adversarial corpus +(extra fields, wrong versions, oversized payloads, URL smuggling, +non-canonical base64) runs through the Rust validator, the generated TS +parser, and the generated inline fragment in CI. ### 6.8 Bridge hardening -As revision 6: parse → identify TS-reserved (live **or tombstoned**, G2) → -`stopImmediatePropagation` → validate source (bounded walk) → -nonce/token/`nav_gen`/`refresh_gen` → respond or refuse. Non-TS ids -untouched. Stolen-token test proves neither TS nor native Prebid responds. +Processing order (normative; preserves the baseline defense that stops +propagation before source validation, `gpt/index.ts:1584-1637`): + +1. parse `e.data` (bare catch → return); +2. identify a TS-reserved ad id — **live registry or tombstone** (G2); +3. if TS-reserved: `stopImmediatePropagation()` before any validation — + a rejected foreign frame must not be answerable by Prebid's native + handler either; +4. validate source ownership via the bounded walk: known slot-root + `WindowProxy` map, the sender's own parent chain + (`event.source.parent`, …) to depth 5 — never scanning an + attacker-controllable frame tree; +5. validate nonce, token, `nav_gen`, `refresh_gen` (G4b); +6. respond, or refuse with `bridge_id_mismatch`. + +Non-TS ad ids are untouched. The stolen-token browser test asserts +**neither TS nor the native Prebid listener responds**; listener +registration order has a real-browser assertion. Renderer branches emit +the full §5.1 sequence with G4d notifications only on carrying paths. ## 7. TSJS target architecture ### 7.1 Layering -As revision 6, with G3's corrected lint mechanics and the adapter-scope -clarification (external ad-tech globals only). - -### 7.2 Adapters / 7.3 Slot registry - -As revision 6 (registry holds the G4a causal queue; cycle/drain state -RuntimeSession; unissued intents NavigationSession). - -### 7.4 Final global surface - -Revision 6's table **plus** the row the review found missing: - -| Legacy surface | Final shape | -| -------------- | ----------------------------------------------------- | -| (new, public) | `tsjs.definePlugin({id, release, install, dispose?})` | - -Bootstrap: field-wise idempotent init (`window.tsjs ||= {}; tsjs.que ||= -[]; tsjs.boot ||= {}`; the ad-slot script's `window.tsjs = {}` at -`publisher.rs:3665` is fixed); **transactional ownership**: states -`unclaimed → installing → kernel | fallback`. The kernel installs wrappers -**inert** and flips them live at a single commit point; on a throw before -commit it unwinds its registered disposers (the §7.6 machinery applied to -kernel boot itself) and marks `failed`; the 10 s watchdog treats a stuck -`installing` as failed; **fallback claims ownership only from -`unclaimed | failed`** — never beside a committed kernel. A bundle -arriving after fallback committed defers for the page (`bundle_partial`). -Tests: throw injected after each boot checkpoint. - -### 7.5 Messaging / 7.6 Plugins and sessions / 7.7 Bootstrap / 7.8 GPT / 7.9 Decomposition +``` +kernel/ boot, config, queue, event bus, log, beacon, sessions +adapters/ googletag.ts, pbjs.ts, messaging.ts ← only access to external ad-tech globals +services/ slots (registry+handoff), auction client, render engine, consent +integrations/ gpt, prebid, aps, creative, datadome, … +``` -As revision 6 (plugin API object form with `release`; transactional -install; sessions; generated no-bundle fallback; unconditional GPT -subscriptions; decomposition table). +Enforced by the two G3 lint rule families. Dissolves the audited +inversions (`core/auction.ts`/`core/request.ts` → +`integrations/aps/render`; `gpt`/`prebid` → `aps`; `prebid` owning the +GPT refresh wrapper). Kernel imports nothing above it; adapters import +kernel only; services import kernel + adapters; integrations import +kernel + services, never each other; stateful services via the G3 +registry only. + +### 7.2 Adapters + +Per external global: `present | pending | timed_out`; `timed_out` is +non-terminal (late GPT/pbjs/CMP arrival transitions to `present` and +drains what is still valid); queued operations carry their own timeouts +and expire with disposition reasons. + +### 7.3 Slot registry service + +Kernel-owned; `WeakMap` + div-id index; +ownership (ts/publisher/adopted), adoption, handoff claims, responsive +resolution, the G4a causal intent queue (NavigationSession for unissued +intents) and cycle/drain state (RuntimeSession), targeting-key history. +No expandos on GPT objects (`__tsRenderGeneration`/`__tsRenderBid` +deleted). + +### 7.4 Final global surface (hard cutover) + +| Legacy surface (removed at cutover) | Final shape | +| --------------------------------------- | --------------------------------------------------------------------- | +| `window.tsjs.que` | `window.tsjs.que` — unchanged, the one public queue | +| `globalThis.tscreative` | `tsjs.creative.*` | +| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | +| `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) | +| `window.__tsjs_*` flags, config globals | `tsjs.boot.*` | +| install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) | +| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` | +| `tsjs._internal` | kernel registry (G3), frozen after boot | +| (new, public) | `tsjs.definePlugin({id, release, install})` | + +**Bootstrap correctness and transactional ownership:** every +server-injected initializer creates the container **idempotently, +field-wise** — `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}` +(the ad-slot script's `window.tsjs = {}` at `publisher.rs:3665` is +fixed). Ownership states: `unclaimed → installing → kernel | fallback`, +with an **owner generation** counter. The kernel installs wrappers +**inert** and flips them live at a single commit point; a throw before +commit runs the shared unwind inventory and marks `failed`. **The +watchdog path is race-free:** on a 10 s stuck `installing`, the +watchdog aborts the owner-generation-scoped `AbortController`, runs the +same shared unwind inventory to completion, and only then atomically +transitions `failed → fallback`; **every late kernel continuation and +disposer validates the owner generation** and self-discards on +mismatch — a resumed async installation can neither overwrite fallback +wrappers nor perform a stale commit. A bundle arriving after fallback +committed defers for the page (`bundle_partial`). Tests: throws +injected after each boot checkpoint **and** hung checkpoints that +resume after fallback claims ownership. + +### 7.5 Messaging module + +All `postMessage` through one module: versioned envelopes, name +constants (the `'Prebid Request'` literal appears at six sites today; +the APS handshake existed in three copies), G4b nonces, §6.8 +validation. The minimal module (envelope + constants + validators used +by the bridge) lands in Phase 1; full call-site migration in Phase 4. + +### 7.6 Plugin lifecycle — transactional — and sessions + +`tsjs.definePlugin({id, release, install})` — object form; `release` is +the build-generated `release_id` constant; **there is no plugin-level +`dispose` hook** — disposal is exclusively `ctx.onDispose` +registrations, which have exactly-once reverse-order semantics +(revision 7's optional `dispose?` had no defined ordering and is +removed). `install(ctx): void | Promise`: + +- `ctx.signal` (aborted on quarantine/disposal); synchronous + `ctx.onDispose(fn)`; effects registered as they are made; + reverse-order unwind on throw/reject/abort; per-disposer exception + isolation; a disposer registered after disposal is invoked + immediately; pending late registrations capacity 16, bound 10 s → + `bundle_partial`; release mismatch quarantines before `install`. +- Sessions: `RuntimeSession` (page lifetime: bridge listener + + reservation store, history hook, pbjs subscriptions, adapters, beacon + queue, physical slot cycle/drain state, in-memory diagnostic + credential); `NavigationSession` (per navigation: trace + + authorization + renewal timer, render attempts, slot aliases, + unissued intents, targeting history); `RenderAttempt` (per G4a cycle + / G4f attempt). Each owns an enumerable disposal inventory; + navigation disposes NavigationSession children only. +- Error policy: no empty `catch` — handle, log with context, or emit a + disposition. The auction fetch gains timeout + `AbortController` and + the G4f discriminated result. +- **Console logging retained, not replaced:** every issue-surfacing + condition keeps or gains a `log.warn` carrying the same reason code + as its beacon event; `debug`-level delivery/security failures are + promoted to `warn`. + +### 7.7 Bootstrap + +`gpt_bootstrap.js` (495 ES5 lines duplicating handoff/initial-load/ +hydration logic, with the live `servicesEnabled` divergence) shrinks to +a queue-and-flags stub; the bundle replays recorded early calls on +install (browser specs cover replay timing); the no-bundle fallback +("ads render if the bundle fails", pinned by `gpt.rs:1174-1179`) is +**generated from the same TypeScript source** at build time, activated +per §7.4's transactional rules. + +### 7.8 GPT correctness carried with the restructure + +Unconditional early `slotRequested`/`slotRenderEnded` subscription +(replacing the `!servicesEnabled` gate, `gpt/index.ts:1091`; idempotent +recording); restore the #922 orphan-slot recovery and `updateRender` +enrichment (DR-3 decides #997 vs re-merge); `changeCorrelator: false` +on TS-initiated refreshes (configurable); `enableSingleRequest()` only +when GPT services are not already enabled; ambiguous responsive +resolution emits `render_fail{slot_unresolved}` alongside its console +warning. + +### 7.9 Decomposition targets + +| Today | Target | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | +| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics | +| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | +| `core/trace.ts` (model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | +| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal | + +### 7.10 Performance (reproducible) + +- **Dedicated workflow** pinned `runs-on: ubuntu-24.04` (browser CI is + `ubuntu-latest` today, `integration-tests.yml:155`) inside a pinned + container image digest; browser = the lockfile-resolved + `@playwright/test` build with its browser revision recorded in the + baseline artifact (the manifest is a caret range, + `browser/package.json:10` — lockfile + recorded revision are + authoritative); compressors pinned by version in the container; + deterministic flags (`gzip -9 -n`, `brotli -q 11`). +- **Module vectors enumerated:** minimal = `[core]`; reference = + `[core, creative, gpt, prebid, datadome]`; maximal = all 13 + discovered modules. Budgets: raw/gzip/Brotli per bundle per vector vs + checked-in baselines (`perf/baselines/*.json`; updates are reviewed + diffs recording image/browser/tool versions; a baseline update is + invalid if any pinned component differs); +5% bytes. +- **Browser timing:** marks `performance.mark("tsjs:bids-script")` + (emitted by the injected bids script) to + `performance.mark("tsjs:first-display")` (emitted by the adapter + wrapper at the first `display()`/`refresh()` dispatch); reference + fixture page; warm HTTP cache; all resources local; 5 warm-ups + discarded, 50 samples; p90 = nearest-rank; gate p90 ≤ baseline × + 1.10; inconclusive (3-run agreement worse than 5%) → one rerun, then + fail. **Maximal-vector peak JS heap ≤ baseline × 1.10.** +- **Server benchmark:** the G5 lookup path; 100 warm-ups, 1,000 + iterations; median and p90; one-sided ≤ baseline × 1.10; 3 + consecutive runs within 5% or inconclusive (rerun, never pass). -### 7.10 Performance (fully reproducible) +### 7.11 Toolchain -Revision 6's pinned workflow, plus the missing definitions: **module -vectors enumerated** — minimal = `[core]`; reference = `[core, creative, -gpt, prebid, datadome]`; maximal = all 13 discovered modules. **Browser -timing marks**: `performance.mark("tsjs:bids-script")` emitted by the -injected bids script and `performance.mark("tsjs:first-display")` emitted -by the adapter wrapper at the first `display()`/`refresh()` dispatch; -metric = duration between marks on the reference fixture page (the -integration-tests reference page), warm HTTP cache, all resources served -locally (no external network); p90 = nearest-rank over 50 samples; -inconclusive (3-run agreement worse than 5%) → one rerun, then fail. -**Maximal-vector peak JS heap ≤ baseline × 1.10.** Server benchmark: the -G5 lookup path (not concatenation), 100 warm-ups, 1,000 iterations, median -and p90, one-sided ≤ baseline × 1.10. +TypeScript floor to the resolved 5.9 line (lockfile resolves 5.9.3 +under the stale `^5.5.4` manifest). **Release-gating flags:** `strict`, +`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, +`verbatimModuleSyntax`, `noImplicitOverride`, +`useUnknownInCatchVariables`. **Gate command (checked-in npm script +`typecheck`):** -### 7.11 Toolchain +``` +cd crates/trusted-server-js/lib && npx --no-install tsc -p tsconfig.json --noEmit +``` -TypeScript floor to the resolved 5.9 line. **Release-gating flags, -enumerated:** `strict`, `noUncheckedIndexedAccess`, -`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, -`noImplicitOverride`, `useUnknownInCatchVariables`; CI command: -`npx tsc -p crates/trusted-server-js/lib/tsconfig.json --noEmit`. Dev -toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from -casual bumps; monthly review. +(runs where the pinned compiler is installed; `--no-install` guarantees +the lockfile-resolved binary). Dev toolchain bumps as individual +CI-gated PRs with changelog review (this library monkeypatches +`fetch`/`sendBeacon`/DOM prototypes); `prebid.js` excluded from casual +bumps (runtime Prebid is the manifest-locked external bundle; npm pin +and deployed bundle version documented together); monthly review. ## 8. Rollout -Single-release state machine per §0 (sticky-cohort affinity). **The -normative gates table ships with this design as Appendix A**, including -initial thresholds, queries, commands, cohort keys, sample floors, owners, -hold/rollback actions, and the real-GAM suite's operational row. Threshold -changes require reviewed decision records. - -Phases (build milestones inside the one release): +Single-release state machine per §0 (authenticated sticky-cohort +affinity; infrastructure-attributed cohorts). The normative gates table +is Appendix A; every gate names a **checked-in artifact** (versioned +Tinybird pipe under `tinybird/pipes/`, script under `scripts/gates/`, +or workflow under `.github/workflows/`) — prose never substitutes for +an executable reference. Threshold changes require reviewed decision +records. + +**Phase 0 decision records** (owner, evidence, deadline, explicit +go/no-go): DR-1 mediator presence (gates §6.1's Phase-3 scope); DR-2 +script creatives — a **deployment decision** (§6.3); DR-3 #997 vs +re-merge (#922 restoration path); DR-4 mediator candidate-id echo owner +and timeline (`merge_highest_cpm` is config-blocked until delivered); +DR-5 non-Fastly sinks (splits Phase-2 gates). - **Phase 0 — Identity, schemas, toolchain, decisions.** Release-time - asset materialization; `format_version`; §5.6 schemas writer-off; - toolchain floors; dead expando deletion; drop surfacing; decision - records DR-1..DR-5 (DR-2 now a deployment decision, §6.2–6.5; DR-4 - gates `merge_highest_cpm`; DR-5 splits Phase-2 gates). + asset materialization; `format_version` + config-hash verification; + §5.6 schemas deployed writer-off; toolchain floors; dead expando + deletion; §5.8 drop surfacing; the five DRs; the gate artifacts + themselves (pipes/scripts/workflows). - **Phase 1 — Kernel, sessions, minimal messaging, cycle registry, transactional bootstrap ownership.** -- **Phase 2 — Trace + beacon.** All three issuance paths + renewal + - diagnostic credential + probe mode; four-adapter ingest incl. - `/_ts/trace-auth` in the isolation family; heartbeats. Gates split: - HTTP parity (all adapters) vs persistence (sink-backed). -- **Phase 3 — APS delivery.** As revision 6, plus `notification_sent` - observability and per-flow funnel gating (the `flow` field): expected- - stage tables per flow live in the gates file; denominator = server- - observed eligible APS wins **within sampled/diagnostic traces**; - `cycle_unattributable` gated; one-sided non-inferiority for fill/p95; - duplicate-`burl` invariant via `notification_sent` key hashes. -- **Phase 4 — Structure.** Layering, plugins, adapters, registry, - messaging, namespace; four-flow behavioral parity. -- **Phase 5 — Decomposition + cutover.** File splits; bootstrap stub + - generated fallback (error/hang/arbitration tests); four-flow parity - rerun; **then the full Phase-3 statistical canary/control gate and the - real-GAM suite are repeated on the exact immutable release candidate** - before router weight rises beyond the low-weight canary; then weight-up, - purge, 24 h monitored window. +- **Phase 2 — Trace + beacon.** Issuance on all three paths + renewal + + diagnostic upgrade + probe mode; four-adapter ingest incl. + `/_ts/trace-auth`; per-sink probes. Gates split per DR-5: HTTP parity + (all adapters) vs persistence (sink-backed). +- **Phase 3 — APS delivery.** Schema crate + corpus; §6.1 with required + `winner_selection` + `[auction].currency`; render token + reservation + store; renderer route + three-message ack + CSP report route; §6.8; + G4a–G4g; `notification_sent` with server-minted `notif_id`; fallback; + DR-3 restoration; correlator + SRA fixes. +- **Phase 4 — Structure.** Full layering + both lint families; plugin + lifecycle; adapters; full slot registry; full messaging migration; + final namespace; four-flow behavioral parity. +- **Phase 5 — Decomposition + cutover.** File splits; script-guard + consolidation; bootstrap stub + generated fallback (error/hang/ + arbitration tests); four-flow parity rerun; **the full Phase-3 + statistical canary/control gates and the real-GAM suite repeat on the + exact immutable release candidate** before router weight rises beyond + the low-weight canary; then weight-up, purge, 24 h monitored window. + +**Statistical method (normative for A.1 production gates):** +populations are **sampled traces only** — diagnostic traffic is +operator-selected and failure-enriched, so it is reported separately +and never enters a statistical gate. Assignment unit = the sticky +cohort token (browser session); cohorts randomized at HTML request; +stratification by publisher and slot. Non-inferiority gates use +**one-sided 95% confidence bounds on the relative difference** +(canary/control − 1 ≥ −2% for fill and billing-per-1,000-attempts; +canary/control − 1 ≤ +2% for p95 latency); improvements always pass. +Floors are **per flow per arm** (Appendix A); flows that cannot reach +their floor in the window (direct, fallback at low adoption) are gated +hermetically and by the real-GAM suite instead of statistically — a +rare flow never permanently blocks rollout, and a statistical gate that +cannot reach its floor is **inconclusive** (extend once, then Hold). +`cycle_unattributable` is divided by **all TS request cycles that were +candidates for attribution** — the failures live in their own +denominator. Missing telemetry counts as failure. Billing reconciliation +(§G4d) runs alongside as the authoritative duplicate check. ## 9. Test acceptance matrix -Revision 6's matrix stands, with these added/changed rows: - -| Area | Added coverage | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Request cycles | publisher `display()` under disabled initial load retired at issuance; publisher-display → TS-refresh attribution test; symmetric ambiguity | -| Trace auth | renewal presents current token and preserves mode; renewal-after-expiry fails closed; deterministic sampling (same trace → same mode across concurrent requests) | -| Diagnostic | credential issuance under admin auth + CSRF; fragment→sessionStorage transport; upgrade of an initial sampled trace; auth `exp` capped at credential expiry; forgery/wrong-origin | -| Trace-auth route | four-adapter parity; wrong-method 405; dispatch before filters; no forwarding; own limiter bucket | -| Affinity | sticky-cohort coherence: new-pool HTML never fetches control-pool assets/APIs (cache-key + routing test); rollback binding prevalidation | -| Join keys | `auction_id` echo on all three paths; attempt-grain join uniqueness under repeated same-slot auctions; `release_id` cohort attribution | -| Funnels | `flow` field set per path; per-flow expected-stage conformance (SSAT, prebid, page-bids, direct, fallback) | -| Tombstones | union capacity 320; unexpired never evicted; >320 registrations then late oldest-id request suppressed; `registry_full` on refusal | -| Ack per path | all four G4b sequences incl. adm reporter snippet; per-path document/runner deadlines; cancellation on navigation | -| Terminal states | exactly one terminal per attempt; post-accept runner failure emits observation + `billing_outcome`, no second terminal | -| Notifications | `notification_sent` emitted per dispatch; duplicate-key-hash query returns zero in canary; direct-flow `(bid_id)` identity; server preserves/expands `nurl`/`burl`; client validates | -| Bootstrap | inert-install + commit flip; throw after each checkpoint unwinds; watchdog claims only from failed/stuck; fallback-then-late-bundle deferral | -| Overflow | out-of-band counter; coalesced overflow event in next flush; no recursion at full queue | -| Heartbeats | probe mode not sampled out; excluded from product denominators; freshness/loss queries from `expected_seq` | -| CSP ingest | pre-buffer caps (8 KiB / 10 reports / 256 chars / depth 4); both media types; opaque origin; policy-id path identity; aggregate schema rows | -| Mediation | required upstream id bounds; fingerprint fallback determinism under arrival shuffle; duplicate-id dedup; adm-swap reclassification; APS + non-USD startup error | -| Limiter | TTL reclaim under saturation; unknown-address bucket; Fastly overshoot bound; XFF hop selection | -| Perf | marks present; vector contents; heap budget; inconclusive-rerun policy | -| Lint | member-expression access to `googletag`/`pbjs` via `window`/`globalThis`/`self`/aliases caught outside adapters | -| Kill switch | pre-commit attempts cancelled; post-commit attempts run to terminal; snapshot semantics | - -## 10. Alternatives / 11. Risks - -As revision 6, plus: **rejected** — per-request Fastly concatenation -(replaced by release-time materialization); trusting client-asserted -sampling on renewal (replaced by token-presentation renewal); FIFO -tombstone eviction (replaced by union capacity with refusal). Risk added: -sticky-cohort routing is new infrastructure the cutover depends on — it is -Phase 0 work and its coherence test is release-gating. +Hermetic CI blocks PRs; the real-GAM suite is release-gating per +Appendix A.3. + +| Area | Must cover | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Request cycles | intent-vs-request; disabled-initial-load `display()` retired for **any caller**; publisher-display → TS-refresh and TS-noop-refresh → TS-display supersession (same-class stealing); SRA; `intent_no_request`; overlap quarantine; no timeout re-arm; stale discard; real-GAM overlap | +| Ack protocol | all four G4b sequences incl. the adm reporter; three-message APS sequence; five-field validation; SSAT/client-Prebid/SafeFrame; stale/replayed acks; acks after navigation disposal; per-path deadlines | +| Bridge security | propagation stopped before validation; neither TS nor native Prebid responds to stolen ids; prior-navigation ids suppressed via the reservation store after disposal; bounded parent-chain walk; listener order (real browser) | +| Render semantics | binds per flow (never targeting); `burl` at accepted; attempt idempotency; `notification_sent{notif_id}` per dispatch; duplicate-detection alarm; accepted-but-blank honesty; `gam_collapsed{action}` emission + guarded resize | +| Direct `/auction` | trace header + `ext.trusted_server.trace` echo; discriminated auction-client errors (timeout/network/http/invalid vs `no_bid`); per-slot latest-wins with reversed responses; generation checks; `RequestAdsResult` settlement; server preserves + expands `nurl`/`burl`, client validates | +| Fallback | child-attempt identity with `parent_flow`; renders only on attributed parent `gam_empty`; publisher-initiated never; timeout never renders; SPA cancellation; kill-switch pre-commit cancellation (commit = earliest irreversible action) | +| Mediation | required-unique upstream ids (`missing_bid_id`/`duplicate_bid_id` rejection — no fingerprint); `candidate_id` echo; arrival-order shuffle invariance; authoritative-field rules (repricing kept; any render-source difference → native); provenance fail-closed scope; strategy-specific timeouts; both lifecycles; APS + non-USD startup error | +| Render token | format/CSPRNG/retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scope; union capacity 320 with `registry_full`; >320 then late oldest-id suppressed | +| Trace auth | auth ≤ 256 B; encoding vectors (kid charset, canonical exp, u32/u64 BE prefixes, unpadded base64url); expiry/skew/max-future; renewal preserves mode via token presentation; renewal-after-expiry fails closed; previous-key retention; deterministic sampling (same trace → same mode concurrently; exact u64 threshold algorithm) | +| Diagnostic | credential issuance under admin auth + CSRF; fragment cleared via `replaceState`; in-memory-only storage; upgrade as the sole mode transition; auth `exp` capped at credential expiry; pre-upgrade local buffering then diagnostic flush; forgery/wrong-origin/replay-past-expiry | +| Trace-auth route | four-adapter parity; wrong-method 405; dispatch before filters; no forwarding; own limiter bucket | +| Affinity | opaque token validation (forged/expired/retired → control + reissue); coherence for HTML, assets, APIs, **beacons, CSP reports**; cache-key normalization; rollback reassignment | +| Join keys | `auction_id` echo on all three paths; attempt-grain join uniqueness under repeated same-slot auctions; infrastructure cohort attribution (per-pool tokens) | +| Funnels | `flow` set per path incl. `system`; per-flow expected-stage conformance; heartbeat/overflow excluded from render denominators | +| Beacon | joins on all three issuance paths; per-trace grouping; seq gaps; duplicate fetch/pagehide deduped in the canonical view; overflow coalescing without recursion; ingest abuse incl. absent Origin; sendBeacon Blob type; `credentials: same-origin` with identity-free handling | +| Ingest/limits | per-adapter limiter semantics as declared; TTL reclaim under saturation; unknown-address bucket; Fastly synchronized-burst behavior documented (> 40 concurrent); XFF hop selection; fail-closed 204 | +| Internal routes | wrong-method 405 + `Allow` + `no-store` on every adapter; unknown version 404; no publisher fall-through; dispatch before auth/EC/filters; no forwarding; per-family origin policies | +| CSP | both media types with separate validators; opaque/null-origin admission; policy-id path identity (forged body URL/version ignored); bucketed aggregation with caps; three-browser capture; per-version frozen header manifest | +| Schema | staleness; adversarial corpus through Rust + generated TS + generated inline fragment; outer tolerance vs exact AAX projection; generated validity matrix; **compile-time exhaustiveness of `AuctionDropReason` over all producers** | +| Runtime ABI | one kernel under concatenation; exact-release verdicts; late registration; failure isolation; object-form `definePlugin` release check | +| Plugins | partial-install unwind; async rejection; abort while pending; disposer-after-disposal; per-disposer isolation | +| Bootstrap | field-wise idempotent init (ad-slot script no longer clobbers); inert-install + commit flip; throw after each checkpoint unwinds; **hung checkpoint resuming after fallback self-discards via owner generation**; fallback-then-late-bundle deferral | +| Lifecycle | `timed_out → present`; session disposal inventories; unissued intents cancelled by navigation disposal; boot container consume/freeze/delete; final-namespace smoke (`tsjs.que`, `tsjs.creative`, async `requestAds`, `definePlugin`) | +| Delivery | unknown hash 410 `no-store`; exact-match immutable with full directive; release-time vector materialization (unlisted vector = build error); config-hash verification failure; cutover rehearsal (weight, purge, rollback) | +| Sinks/monitoring | per-datasource probes (client-events heartbeat, CSP probe policy-id, ops probe counter) per adapter write path; canonical-views-only enforcement (raw-join multiplication test); `publisher_domain` naming | +| Failure injection | Amazon runner redirect/hang/CSP block/script error → distinct §5.1 outcomes; EC/filter failure before renderer dispatch | +| Adapter parity | ingest, CSP-report, trace-auth, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | +| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness per §2.5; kill-switch snapshot semantics | +| Perf | marks present; three vector contents; heap budget; inconclusive-rerun policy; pinned-environment baseline validity | +| Lint | member-expression access to `googletag`/`pbjs` via `window`/`globalThis`/`self`/aliases caught outside adapters | + +## 10. Alternatives considered + +1. Patching APS point-failures without telemetry — rejected: four + correct fixes have not produced reliable ads. +2. Always direct-render APS (skip GAM/PUC) — rejected: changes GAM + reporting/pacing unilaterally; kept as the attributed-`gam_empty` + fallback. +3. Single module graph / shared chunks now — rejected for this release: + changes the delivery pipeline while everything else changes; + successor option behind the same registry surface. +4. Full rewrite in one branch without phases — rejected: the + browser-spec safety net is thinnest exactly where behavior changes. +5. Dropping the ES5 bootstrap — rejected: loses the pinned no-bundle + guarantee; the generated fallback keeps it. +6. Timeout-triggered fallback rendering — rejected: GPT requests cannot + be cancelled; timeout racing a late fill can double-render and + double-bill. +7. Timeout-based quarantine re-arm — rejected (recreates the stale-event + bug). +8. N/N−1 compatibility machinery — removed by the §0 policy decision. +9. Client-computed notification hashes — rejected (no key without + breaking the pseudonymization boundary); server-minted `notif_id`. +10. Fingerprint identities for id-less bids — rejected (can merge + distinct demand); rejection with closed reasons instead. +11. Plain readable cohort cookie — rejected (dark-pool opt-in + + cache-cardinality abuse); opaque authenticated token. +12. `billing_outcome` event — removed (no honest producer exists). + +## 11. Risks + +- Hard-cutover blast radius — accepted by policy; bounded by the §0 + runbook (probes, low-weight canary, 24 h window, weight-back + rollback). +- Sticky-cohort routing is new infrastructure the cutover depends on — + Phase 0 work; its coherence test is release-gating. +- Mediator wire-contract change (`candidate_id` echo) — DR-4 gates + `merge_highest_cpm`; config validation enforces the block. +- Notification triggers become a published contract for PBS-path + demand — changing them later is a breaking change for SSP reporting. +- Required `[auction].currency` and `winner_selection` (mediated + deployments) are a deliberate startup-error class under §0. +- Beacon abuse — pre-parse caps, per-family origin policies, fail-closed + numeric limits, signed modes, credentialed diagnostics. +- Registry/limiter memory — explicit capacities, TTL reclamation, + reject-at-capacity; Fastly overshoot documented, not claimed. +- CSP data is advisory — never a sole rollback signal. +- Sink blindness — per-datasource probes with datasource-side queries. +- ABI freeze — `tsjs._internal.registry` is load-bearing; exact-release + verdicts are the contract. +- Schema generation — checked-in artifacts + staleness CI. ## 12. Success criteria -Revision 6's criteria with these corrections: (2) diagnostic completeness -is achieved via the §2.5 gate split (content vs volume); (5) attempt -counts keyed `(trace_id, nav_gen, refresh_gen, slot)`; (10) the -duplicate-`burl` invariant is measured via `notification_sent` key hashes -in production and by hermetic tests, with external billing reconciliation -as backstop; (add 13) the Phase-3 statistical and real-GAM gates pass on -the exact immutable Phase-5 release candidate before weight-up; (add 14) -the Appendix A gates table shipped with this design and every later change -carries a reviewed decision record; (add 15) the baseline APS fix behaviors -are re-implemented in the target architecture with the baseline browser -tests passing unmodified as the conformance pin. +1. APS creatives render in each configured flow (SSAT, client-Prebid, + page-bids, direct), hermetically and in the release-gating real-GAM + suite per Appendix A.3's enumerated topologies. +2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the + failing class from one page load (including A1–A4 via the debug + envelopes, and including an initially-unsampled page via pre-upgrade + buffering); §5.7 SLIs hold on sink-backed deployments. +3. Both lint families pass with zero exceptions; stateful sharing only + via the registry; exact-release mismatches quarantine loudly. +4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or + generated. +5. Attempt counts key on `(trace_id, nav_gen, refresh_gen, slot)`; + traces stay navigation-scoped; no double counting; orphan recovery + has a non-vacuous test; G4a holds including caller-independent + retirement and same-class supersession. +6. The only TSJS-owned global is `window.tsjs` with the §7.4 final + shape; no expandos; legacy names gone at cutover. +7. §7.10 budgets hold on the dedicated pinned workflow. +8. No existing warning lost; issue-surfacing conditions log `warn`+ + with the beacon's reason code. +9. TypeScript floor matches resolved 5.9 with the §7.11 flags via the + checked-in `typecheck` script; `prebid.js` pin documented with the + deployed bundle. +10. `nurl`/`burl` fire only on carrying paths at their G4d binds, + attempt-scoped and idempotent; APS fires neither; hermetic + exactly-once tests pass; production duplicates alarm via + `notification_sent` and reconcile to zero via billing reports. +11. Trace-bearing responses are `private, no-store`; authorizations + are per-trace, signed, mode-carrying, renewal-preserving, with + diagnostic upgrade as the sole authenticated mode transition; + unsampled traces transmit nothing. +12. The cutover runbook rehearsed (weight switch, purge, rollback); + config-hash verification enforced. +13. The Phase-3 statistical and real-GAM gates pass on the exact + immutable Phase-5 release candidate before weight-up. +14. The Appendix A gates table shipped with this design; every change + carries a reviewed decision record. +15. The baseline APS fix behaviors are re-implemented in the target + architecture with the baseline browser tests passing unmodified. ## 13. Open questions @@ -752,60 +1233,69 @@ post-`render_accepted` state under a new name (future enhancement)? ## Appendix A — Normative rollout gates (initial values) Owners are roles: **RO** = release owner, **QA** = QA owner, **OPS** = -release owner's on-call. Assignment key for canary/control = -sticky cohort (`ts-rel`), randomized at HTML request, per §0. All -production queries run against canonical views only (§5.5). "Hold" = -router weight frozen; "Rollback" = weight to previous release + re-purge. +release owner's on-call. Assignment unit = the authenticated sticky +cohort token (§0), randomized at HTML request, stratified by publisher +and slot. Statistical gates use **sampled traces only** (§8 method); +diagnostic traffic is reported separately. All production queries run +against canonical views via **checked-in versioned pipes** +(`tinybird/pipes/gate_.pipe`); probe/parity suites are checked-in +scripts (`scripts/gates/.sh`) or workflows. "Hold" = router +weight frozen; "Rollback" = weight to previous release + re-purge. Changing any row requires a reviewed decision record. ### A.1 Phase gates -| Phase | Gate | Query / test command | Denominator | Floor | Threshold | Window | Owner | Action | -| ----- | -------------------------- | --------------------------------------------------------------------- | ---------------------------------------------- | ------------- | --------------------------------- | ------ | ----- | -------- | -| 0 | Dark-pool health | probe suite vs dark pool (all four adapters) | probe requests | 1,000 | 100% expected responses | 24 h | OPS | Hold | -| 0 | Schema validation | synthetic writes to `ts_client_events` + auction rows | synthetic rows | 10,000 | rejection < 0.1% | 24 h | RO | Hold | -| 0 | Asset identity | probe: every manifest hash 200-immutable; unknown hash 410 `no-store` | probed hashes | all | 0 misses / 0 wrong-status | once | QA | Hold | -| 1 | ABI cleanliness | probe pages: `abi_mismatch` + `bundle_partial` counters | probe page loads | 1,000 | 0 | 24 h | QA | Hold | -| 1 | Bootstrap ownership | hermetic: throw-after-each-checkpoint suite | checkpoints | all | 100% unwind-to-`failed` | CI | QA | Hold | -| 2 | Ingest HTTP parity | parity suite vs all four adapters (routes, 405s, limits, 204s) | parity cases | all | 100% | CI | QA | Hold | -| 2 | Persistence (sink-backed) | acceptance ≥ 99%; dedup exactly-once per `(trace, seq)` | probe batches | 10,000 evts | as stated | 24 h | OPS | Hold | -| 2 | Heartbeat pipeline | freshness lag; `expected_seq` loss | probe heartbeats | 1,000 | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | -| 3 | APS funnel (per flow) | per-`flow` stage rates from `ts_render_attempts_v` (table A.2) | eligible APS wins in sampled+diagnostic traces | 10,000/cohort | per A.2 | 24 h | RO | Rollback | -| 3 | Attribution soundness | `cycle_unattributable` rate | attributable-candidate cycles | 10,000 | < 0.5% | 24 h | RO | Rollback | -| 3 | GAM fill (non-inferiority) | canary fill vs control | cohort ad requests | 10,000 | canary ≥ control − 2% (one-sided) | 24 h | RO | Rollback | -| 3 | Latency (non-inferiority) | canary p95 bids-to-display vs control | cohort attempts | 10,000 | canary ≤ control × 1.02 | 24 h | RO | Rollback | -| 3 | Billing | GAM/server-side revenue per 1,000 attempts, canary vs control | cohort attempts | 10,000 | canary ≥ control − 2% (one-sided) | 24 h | RO | Rollback | -| 3 | Duplicate `burl` | duplicate `notification_sent{burl}` per `id_key_hash` | burl dispatches | 1,000 | 0 | 24 h | RO | Rollback | -| 4 | Layering | both lint rules; disposal-inventory leak suite | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | -| 4 | Four-flow parity | hermetic parity: SSAT, prebid, page-bids, direct | parity cases | all | 100% | CI | QA | Hold | -| 5 | Parity rerun + budgets | four-flow parity; §7.10 budgets on pinned workflow | — | — | 100% / within tolerance | CI | QA | Hold | -| 5 | RC re-canary | repeat all Phase-3 rows on the immutable RC | as Phase 3 | as Phase 3 | as Phase 3 | 24 h | RO | Rollback | -| 5 | Cutover monitor | §5.7 SLIs post-weight-up | production traffic | — | SLIs green | 24 h | OPS | Rollback | - -Low-volume handling: a production gate that cannot reach its floor within -its window is **inconclusive** — extend the window once; a second -inconclusive result is a Hold, never a pass. +| Phase | Gate | Artifact (checked in) | Denominator | Floor | Threshold | Window | Owner | Action | +| ----- | ----------------------------- | ---------------------------------------------------------- | --------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------- | ------ | ----- | -------- | +| 0 | Dark-pool health | `scripts/gates/probe-pool.sh` | probe requests | 1,000 | 100% expected responses | 24 h | OPS | Hold | +| 0 | Schema validation | `scripts/gates/schema-writes.sh` (deterministic writes) | synthetic rows | 10,000 | **0 rejections** (writes are deterministic) | 24 h | RO | Hold | +| 0 | Asset identity | `scripts/gates/asset-probe.sh` | probed hashes | all | 0 misses / 0 wrong-status | once | QA | Hold | +| 0 | Config binding | `scripts/gates/config-hash.sh` | pools | all | manifest hash verified on every pool | once | OPS | Hold | +| 1 | ABI cleanliness | `gate_abi.pipe` (probe pages) | probe page loads | 1,000 | 0 `abi_mismatch`/`bundle_partial` | 24 h | QA | Hold | +| 1 | Bootstrap ownership | hermetic suite `bootstrap-ownership.spec` | checkpoints incl. hung-resume | all | 100% unwind/self-discard | CI | QA | Hold | +| 2 | Ingest HTTP parity | `scripts/gates/ingest-parity.sh` (4 adapters, 4 families) | parity cases | all | 100% | CI | QA | Hold | +| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | probe batches | 10,000 events | acceptance ≥ 99%; dedup exactly-once | 24 h | OPS | Hold | +| 2 | Per-sink probes | `gate_probes.pipe` (3 datasources × adapters) | probe writes per sink | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | +| 3 | Funnel: ssat/prebid/page_bids | `gate_funnel.pipe` per flow | eligible APS wins (sampled traces), per flow-arm | 10,000 each | per A.2 | 24 h | RO | Rollback | +| 3 | Funnel: direct/fallback | hermetic + real-GAM rows (A.3) — not statistical | suite cases | all | 100% | CI+RG | QA | Hold | +| 3 | Attribution soundness | `gate_cycles.pipe` | **all TS request cycles candidate for attribution** | 10,000 | `cycle_unattributable` < 0.5% | 24 h | RO | Rollback | +| 3 | GAM fill | `gate_fill.pipe` | cohort ad requests per arm | 10,000 | one-sided 95% CB: rel. diff ≥ −2% | 24 h | RO | Rollback | +| 3 | Latency | `gate_latency.pipe` | cohort attempts per arm | 10,000 | one-sided 95% CB: p95 rel. diff ≤ +2% | 24 h | RO | Rollback | +| 3 | Billing | `gate_billing.pipe` + GAM report reconciliation | attempts per arm (per-1,000 normalization) | 100,000 or 7 d | one-sided 95% CB: rel. diff ≥ −2% | window | RO | Rollback | +| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` (detection) + billing reconciliation | burl dispatches | 1,000 | 0 observed duplicates; reconciliation clean | 24 h | RO | Rollback | +| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | +| 4 | Four-flow parity | `flow-parity.spec` (hermetic) | parity cases | all | 100% | CI | QA | Hold | +| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / within §7.10 tolerances | CI | QA | Hold | +| 5 | RC re-canary | repeat all Phase-3 rows on the immutable RC | as Phase 3 | as Phase 3 | as Phase 3 | 24 h | RO | Rollback | +| 5 | Cutover monitor | `gate_slis.pipe` | production traffic | — | probe lag ≤ 5 min; probe loss < 0.1%; `render_fail` rate ≤ pre-cutover canary + 0.5 pt | 24 h | OPS | Rollback | + +Low-volume handling: a statistical gate that cannot reach its floor in +its window is inconclusive — extend once; a second inconclusive is a +Hold, never a pass. ### A.2 Expected stages per flow (Phase 3 funnel) -| Flow | Expected sequence | Stage thresholds | -| --------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_accepted` | each stage ≥ 95% of prior; document-load ≥ 99%; runner fail+timeout ≤ 1% | -| prebid | same as ssat (keyed by Prebid `adId`) | same | -| page_bids | same as ssat (after SPA navigation) | same | -| direct | `render_attempt → renderer_document_loaded → render_accepted` (no bridge stages) | document-load ≥ 99%; accepted ≥ 95% of attempts | -| fallback | `gam_empty → fallback_start → renderer_document_loaded → render_accepted` | accepted ≥ 95% of fallback starts | +Denominators are named per stage; document/runner rates apply wherever +the renderer document participates. + +| Flow | Expected sequence | Stage thresholds (each vs its named denominator) | +| --------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_accepted` | each ≥ 95% of prior; `renderer_document_loaded`/`bridge_response_sent` ≥ 99%; `runner_failed`+timeouts ≤ 1% of `renderer_document_loaded` | +| prebid | same as ssat (keyed by Prebid `adId`) | same | +| page_bids | same as ssat (after SPA navigation) | same | +| direct | `render_attempt → renderer_document_loaded → render_accepted` | document ≥ 99% of attempts; accepted ≥ 95% of attempts (hermetic/real-GAM gate, not statistical) | +| fallback | parent `gam_empty` → child `fallback_start → renderer_document_loaded → render_accepted` | accepted ≥ 95% of `fallback_start` (hermetic/real-GAM gate, not statistical) | ### A.3 Real-GAM suite (operational row) -| Field | Value | -| ------------------ | -------------------------------------------------------------------------------------- | -| Workflow | `real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | -| Topologies | one per flow in A.2, plus publisher-overlap and disabled-initial-load formation (G4a) | -| Browsers | Chromium, Firefox, WebKit (CSP/opaque-origin rows); Chromium (funnel rows) | -| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | -| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | -| Command | `npx playwright test --config real-gam.config.ts` from the browser test package | -| Artifact | Playwright HTML report + trace zips, uploaded as workflow artifacts, retained 90 days | -| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | -| Approval evidence | green workflow run URL linked in the release checklist, signed off by RO | +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | +| Topologies | one per A.2 flow, plus publisher-overlap and disabled-initial-load formation (G4a), plus the same-class supersession case | +| Browsers | Chromium, Firefox, WebKit (CSP/opaque-origin rows); Chromium (funnel rows) | +| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | +| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | +| Command | `npx playwright test --config real-gam.config.ts` from the browser test package | +| Artifact | Playwright HTML report + trace zips as workflow artifacts, retained 90 days | +| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | +| Approval evidence | green workflow run URL linked in the release checklist, signed off by RO | From 0dc1a5da62d9f7374db1e3f6922e7798ed161568 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:40:13 -0700 Subject: [PATCH 010/194] Revise design spec for the eighth review round Revision 9 closes the mandatory set: distinct attempt_id with nullable parent_attempt_id (fallback children are separate rows; attempt views key on attempt_id, the tuple is grouping only); one discriminated render_terminal event replacing the unrepresentable accepted/fail pair; diagnostic renewal bounded forever by a signed dexp ceiling; telemetry batching capped by both events and bytes with ingest budgets derived from worst-case honest traffic so the limiter cannot reject steady state; CSP report affinity routed by server-selected policy_id path instead of the cookie the opaque renderer cannot send; an observation-only control build plus arm-specific datasources and a stamped deployment_pool so the control arm can populate sampled gates; assignment_id as the clustered randomization unit with a checked-in bootstrap estimator and named numerator/denominator/source per gate; t_rel_ms as the monotonic latency basis; the ADM acknowledgement moved into the trusted owner so the nonce never enters the bidder realm; an AuctionBatch owning multi-slot fetch cancellation; a read-only bridge lookup that emits the altered-id matched:false signal without suppressing native Prebid; per-datasource authenticated probes with an injected-failure alert drill; complete CSP and ops schemas, settings, and sink handles; Nullable(UUID) join typing with explicit grains; AuctionDropReason exhaustive over baseline producers via one shared typed enum with a compile-time test; the withdrawn Fastly overshoot multiple; a page-bound kill switch honestly scoped; config_hash SHA-256 verification and a fully specified affinity token with vectors; state-dependent post-cutover routing defaults; the removed plugin dispose hook; normative notification dispatch mechanics; a reproducible CDP heap procedure; and RC attestation binding the real-GAM gate to the exact immutable build. --- ...s-render-fix-and-tsjs-resilience-design.md | 1854 ++++++++--------- 1 file changed, 814 insertions(+), 1040 deletions(-) diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index 74e08c867..b988e6286 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1,83 +1,85 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 8 — reworked after the seventh review round and made - fully self-contained: no contract in this document is defined by reference - to an earlier revision. +- **Status:** revision 9 — closes the eighth review round's mandatory set: + attempt schema, diagnostic renewal, telemetry/gate model, CSP routing, and + rollout measurement. Fully self-contained. - **Date:** 2026-08-04 - **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed GAM shells"). All file:line citations refer to this commit. -- **Inputs:** three code audits; design reviews of revisions 1–7; open issues +- **Inputs:** three code audits; design reviews of revisions 1–8; open issues #926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997. -- **Normative gates:** the initial rollout-gates table is **Appendix A**; - changes require reviewed decision records so thresholds cannot be chosen - after observing results. -- **Adoption stance for the baseline APS fixes (`248fe9558`):** this design - adopts their **contracts** — the MessageChannel handshake semantics, the - collapsed-shell remediation behavior, the consolidated bridge branch — and - **re-implements them inside the target architecture** (the messaging module - owns the channel protocol, the render engine owns the resize, the rebuilt - `render_bridge` module owns the branch). The patch code is not carried - forward; the baseline's browser tests are retained unmodified as the - conformance suite pinning the adopted behavior. +- **Normative gates:** Appendix A ships with this design; changes require + reviewed decision records. +- **Adoption stance for the baseline APS fixes (`248fe9558`):** contracts + adopted (MessageChannel handshake semantics, collapsed-shell remediation, + consolidated bridge branch), implementations rebuilt inside the target + architecture; the baseline browser tests pass unmodified as the + conformance pin. ## 0. Release policy: coordinated hard cutover -One coordinated release: - -- Server, TSJS bundles, config, and HTML ship under one **`release_id`** - (git tag / build hash). **No N/N−1**; in-flight clients may fail at - cutover — accepted and stated, not mitigated. -- **Exact release matching**: kernel, services, plugins, and the install - manifest carry the same `release_id`; mismatch is a refusal. -- **Config is a release-time, content-verified input.** The config blob gains - a top-level `format_version` (exact match required). Publish order: blob - first, deployment manifest second. The manifest binds each pool to - immutable `{release_id, config_store, config_key, config_hash}`, and the - binary **verifies the loaded blob's hash against the manifest at - startup** — a mismatch is a startup failure, so a config overwrite cannot - mutate a supposedly immutable release or invalidate pre-materialized asset - vectors. Rollback = redeploy the previous release with its own verified - config; the rollback binding is prevalidated. -- **Assets:** binaries embed only their release's artifacts; hashed pathnames - exist for cache identity; unknown hash → `410 Gone`, `no-store`. -- **Rollout state machine with authenticated release affinity.** The new - pool comes up fully enabled, reachable only by probes. Canarying uses a - **sticky, opaque, authenticated cohort token**: the router sets `ts-rel` - on the HTML response — an HMAC-signed opaque value binding - `{publisher_host, release_id, cohort, exp}` (attributes: `Secure; -HttpOnly; SameSite=Lax; Path=/`; TTL 24 h) — and routes every subsequent - request by the **validated** token; invalid, expired, forged, or - non-allowlisted tokens route to control and are reissued; tokens for - retired releases are reassigned on next HTML response after rollback. - Cache keys use the post-validation release label (bounded cardinality; - raw cookie values never key caches). A plain readable release id would - let any visitor opt into the dark pool and would hand cache-key - cardinality to attackers — hence opaque and authenticated. Because - affinity rides a cookie, **the beacon and CSP-report transports use - `credentials: "same-origin"`** (not `omit`): the cookie exists for the - routing layer only; application handlers still derive no identity from - it. Router weight over sticky cohorts is the sole activation primitive; - flags are in-pool emergency kill switches. Cutover = weight 100% + CDN - purge; rollback = weight back + re-purge. The affinity acceptance test - covers HTML, assets, APIs, **beacons, and CSP reports**. -- **Canary/control discrimination is infrastructure-attributed:** each pool - writes telemetry with **pool-specific datasource tokens**, so cohort - attribution comes from the write identity, not from in-row fields the - control binary (the baseline) does not emit; in-row `release_id` from the - new pool is secondary confirmation. +- One release: server, TSJS bundles, config, HTML under one **`release_id`**. + No N/N−1; in-flight clients may fail at cutover — accepted and stated. +- **Exact release matching**; mismatch is a refusal. +- **Config is a release-time, content-verified input.** `format_version` + exact-match. **`config_hash` = SHA-256 over the exact fetched envelope + bytes**, bound through compiled release metadata (or a signed manifest + with an embedded verification key); the binary verifies at startup and + fails loudly on mismatch. Publish order: blob, then manifest. Rollback = + redeploy the previous release with its own verified config; binding + prevalidated. +- Assets: embedded only; hashed pathnames for cache identity; unknown hash → + `410`, `no-store`. +- **Authenticated sticky affinity.** The router sets `ts-rel` on HTML + responses — format `r1...... +`: `kid ^[a-z0-9-]{1,16}$`; canonical decimal `exp` (TTL 24 h); + `release` from the allowlist; `cohort ∈ {canary, control}`; + `assignment_id` = 32-hex CSPRNG (the pseudonymous **randomization unit**, + §8); `sig` = unpadded base64url HMAC-SHA-256 over the domain-separated + length-prefixed input `"ts-affinity-v1" || u32be-len fields || +u64be(exp)`; keys owned and rotated by the routing layer (active + + previous, ≥ 24 h retention); constant-time verification; test vectors + checked in. Attributes `Secure; HttpOnly; SameSite=Lax; Path=/`. Cache + keys use the post-validation release label only. +- **State-dependent routing defaults:** during canary, valid tokens route by + their binding; invalid/expired/forged → control + reissue. **After forward + cutover ("weight 100%"), the safe default flips:** stale, invalid, or + control-bound tokens on HTML requests are reassigned to the active + release; non-HTML requests with unknown or stale tokens route to the + active release — 100% means 100%, not "except 24 h of old cookies." + Rollback flips the default back. +- **CSP-report affinity never depends on cookies:** the renderer is + sandboxed without `allow-same-origin` (`aps/render.ts:4`), so its + browser-generated reports are cross-origin to the publisher endpoint and + carry no cookie. Release/cohort identity is encoded in the + **server-generated report path's `policy_id`** (minted per + `{release, policy version, cohort}`, registered in the header manifest); + the router routes `/_ts/csp-reports/` by that registry. +- Beacon and trace-auth transports use `credentials: "same-origin"` so the + affinity cookie routes them; handlers derive no identity from cookies. +- **Canary/control measurement (closing the empty-control-arm gap):** the + control pool for Phase-3 statistics runs an **observation-only control + build** — the baseline plus Phase-2 instrumentation only (trace, beacon, + probes; zero behavior changes) — so both arms emit comparable sampled + telemetry. Arms write to **arm-specific datasources**; the canonical + union view stamps a trusted `deployment_pool` dimension from the write + identity (token → dataset → arm), making the arm a queryable row + dimension rather than an authorization side effect. +- Router weight over sticky cohorts is the sole activation primitive; flags + are in-pool emergency kill switches. Cutover = weight 100% + CDN purge; + rollback = weight back + re-purge. The affinity acceptance test covers + HTML, assets, APIs, beacons, and CSP reports (via path identity). ## 1. Problem statement -APS demand is fully integrated server-side — the edge runs the APS OpenRTB -auction, wins bids, and ships a typed renderer descriptor — yet APS -creatives do not appear reliably. Four serial fixes (the `bid.meta` -carrier, the decoupled shim, the `hb_adid` fallback, the baseline -PUC/collapsed-shell fix) each survived review; the pattern is the finding: -**multiple independent failure points, most failing silently**, with no -client→server signal about which fired. The TSJS library (56 files, -~11,900 lines, two ~1,800-line monoliths, duplicated ES5/TS logic, -inverted layering, ~100 error-swallowing catches) is the same problem -structurally. +APS demand is fully integrated server-side, yet APS creatives do not appear +reliably. Four serial fixes (the `bid.meta` carrier, the decoupled shim, +the `hb_adid` fallback, the baseline PUC/collapsed-shell fix) each survived +review; the pattern is the finding: **multiple independent failure points, +most failing silently**, with no client→server signal about which fired. +The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths, +duplicated ES5/TS logic, inverted layering, ~100 error-swallowing catches) +is the same problem structurally. ### Non-goals @@ -127,28 +129,25 @@ server-side to one that painted. ### 2.5 Failure → signal mapping (normative) -Two gates, precisely separated: the **tester cookie** (explicitly -non-security, `tester_cookie.rs:3`) gates **debug content** — the -`tsjs.boot.debug` envelope and the page-bids/`/auction` -`ext.trusted_server.debug` fields, the same sensitivity class as the -existing tester-gated `ts-debug` comment. The **diagnostic credential** -(§5.3) gates **telemetry volume**. The cookie never affects sampling; the -credential never gates mere content. - -| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface | -| ------- | ----------------------------------------- | ------------------------------------- | ------------------------------- | -| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary | -| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary | -| A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary | -| A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` | -| B1/B2 | `bridge_request{matched:false}` | join via trace (§G1) | console warn | -| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn | -| C2 | `render_fail{renderer_document_no_load}` | `ts_ops_counters` | console warn | -| C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn | -| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn | -| C5 | — (fixed at baseline) | — | — | -| C6 | `runner_failed` + CSP buckets | `ts_csp_reports` | console warn | -| C7 | full §5.1 sequence from renderer branches | join via trace | debug/warn | +The **tester cookie** (non-security, `tester_cookie.rs:3`) gates **debug +content** (`tsjs.boot.debug`, response `ext.trusted_server.debug` — same +class as the existing `ts-debug` comment). The **diagnostic credential** +(§5.3) gates **telemetry volume**. Neither crosses into the other's role. + +| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface | +| ------- | ---------------------------------------------------- | ------------------------------------- | ------------------------------- | +| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary | +| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary | +| A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary | +| A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` | +| B1/B2 | `bridge_request{matched:false}` (§6.8) | join via trace | console warn | +| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn | +| C2 | `render_terminal{failed, renderer_document_no_load}` | `ts_ops_counters` | console warn | +| C3 | `render_terminal{failed, bridge_id_mismatch}` | join via trace | console warn | +| C4 | `render_terminal{failed, descriptor_invalid}` | schema corpus CI | console warn | +| C5 | — (fixed at baseline) | — | — | +| C6 | `runner_failed` + CSP buckets | `ts_csp_reports` | console warn | +| C7 | full §5.1 sequence from renderer branches | join via trace | debug/warn | ## 3. The GPT and baseline reality @@ -159,303 +158,243 @@ credential never gates mere content. 4. `enableSingleRequest()` called blind after publisher `enableServices()`. 5. Responsive-resolution ambiguity silently skips slots. 6. Three independent `pubads().refresh` wrappers. -7. **GPT has no cancellation, no per-refresh identity, no completion-order - guarantee**; `slotRenderEnded` = code injected, not resources loaded; - `responseIdentifier` identifies responses only. -8. **`display()` under disabled initial load creates no request — for any - caller** (`gpt/index.ts:1175`, `ad_init.test.ts:1201-1263`); GPT's - behavior is caller-independent. +7. GPT has no cancellation, no per-refresh identity, no completion-order + guarantee; `slotRenderEnded` = code injected; `responseIdentifier` + identifies responses only. +8. `display()` under disabled initial load creates no request — for any + caller (`gpt/index.ts:1175`, `ad_init.test.ts:1201-1263`). 9. `slotRenderEnded` registration gated behind `!ts.servicesEnabled` - (`gpt/index.ts:1091`); G4a needs unconditional early subscription. -10. Baseline fix `248fe9558`: MessageChannel APS-PUC handshake - (`aps.rs:65-125`, `aps/render.ts:415-437`; the reply still terminates - inside the PUC frame), collapsed-shell resize (`gpt/index.ts:217`), C5 - consolidated, real-PUC browser test added. -11. The bridge keeps consumed-id tombstones for security - (`gpt/index.ts:1527`). + (`gpt/index.ts:1091`). +10. Baseline `248fe9558`: MessageChannel APS-PUC handshake + (`aps.rs:65-125`, `aps/render.ts:415-437`), collapsed-shell resize + (`gpt/index.ts:217`), C5 consolidated, real-PUC browser test. +11. The bridge keeps consumed-id tombstones (`gpt/index.ts:1527`). 12. The tester cookie is not a security control (`tester_cookie.rs:3`). -13. **Fastly constructs application state per request** (`app.rs:146`); - its platform rate counter is a 60 s fixed window with separate - lookup/increment (`rate_limiter.rs:40`). +13. Fastly constructs application state per request (`app.rs:146`); its + platform counter is a 60 s window with separate lookup/increment + (`rate_limiter.rs:40`). 14. The baseline auction client collapses every failure into an empty - array (`core/auction.ts:185-224`): absent fetch, timeout, network - error, non-2xx, wrong content type, malformed body, and a genuine - zero-bid auction are indistinguishable to callers. + array (`core/auction.ts:185-224`). +15. A single `/auction` request can carry several slots; concurrent calls + race today (`request.ts:31`) and share one fetch. ## 4. Design gates -### G1 — Trace identity, sampling, correlation +### G1 — Trace identity, attempts, sampling, correlation -- The client-visible auction id is EC-derived (`publisher.rs:3237`) and - never ingested. Initial-HTML telemetry precedes page JS - (`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by - whoever acts first: the **server** for `nav_gen 0` (trace + signed - authorization in `tsjs.boot`); the **client** afterwards via - `X-TSJS-Trace-Id` on page-bids (GET) and the `/auction` POST, echoed - back with the authorization and the server's telemetry auction id: +- EC-derived auction ids (`publisher.rs:3237`) are never ingested. + Initial-HTML telemetry precedes page JS (`telemetry.rs:148`, + `publisher.rs:2452`); correlation is minted by whoever acts first: the + server for `nav_gen 0` (trace + signed authorization in `tsjs.boot`); + the client afterwards via `X-TSJS-Trace-Id` on page-bids (GET) and the + `/auction` POST, echoed back with `ext.trusted_server.trace = {trace_id, auth, auction_id}`. -- **Deterministic keyed sampling, numerically exact:** take the first - 8 bytes of `HMAC-SHA-256(sampling_key, trace_id)` as a big-endian u64; - `mode = sampled` iff `u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate` - must be finite and in `[0, 1]` (validated at load; 0 → nothing sampled, - 1 → everything). Concurrent requests for one trace always derive the - same mode with no shared state (§3.13). -- **Cross-tier join:** the equality key is the globally unique - **`auction_id`** (server-minted telemetry UUID), echoed to the client - and stamped on every event of attempts born from that auction. - Generations (`nav_gen`, `refresh_gen`) exist **client-side only**, for - attempt aggregation — auction rows do not carry them. Canonical join: - `(publisher_domain, trace_id, auction_id)`, attempt grain added from - client events. -- **Cache-privacy invariant:** traces/authorizations only in per-request - auction-bearing responses; such HTML is `private, no-store`, no - validators; by construction and by test. +- **Attempt identity (closing the parent/child collision):** every render + attempt mints a client-side **`attempt_id`** (8-char `[a-z0-9]`, + CSPRNG, unique per trace) and carries nullable **`parent_attempt_id`** + (fallback children reference their parent). The tuple + `(trace_id, nav_gen, refresh_gen, slot)` remains a **grouping key + only**; `ts_render_attempts_v` keys by `attempt_id`. Exactly one + terminal event per `attempt_id` (G4c) is a tested invariant. +- **Deterministic keyed sampling:** first 8 bytes of + `HMAC-SHA-256(sampling_key, trace_id)` as u64 BE; `sampled` iff + `u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate` finite in `[0, 1]`. +- **Cross-tier join:** equality key = the server telemetry + **`auction_id`** (UUID; the client column is `Nullable(UUID)` and + ingest validates canonical UUID syntax). Generations are client-side + only. **Join grains are explicit:** auction-level joins hit the one + summary row per `auction_id`; slot-level joins use + `auction_id + slot + row_kind`; bid-level joins are opt-in for + bid-grained analyses. Raw attempt × auction-row joins are forbidden + (row multiplication). +- Cache-privacy invariant: traces/authorizations only in per-request + auction-bearing responses; such HTML is `private, no-store`. - Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry - `{nav_gen, refresh_gen, seq, flow, auction_id?}`. **`flow`** is closed: - `ssat | prebid | page_bids | direct | fallback | system` — `system` for - heartbeat and overflow events, which have no render flow; the generated - per-event validity matrix (§5.1) says which events may carry which - flows. -- Traces are navigation-scoped; attempt counts key on - `(trace_id, nav_gen, refresh_gen, slot)`. + `{nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?, +auction_id?, t_rel_ms?}`. **`t_rel_ms`** is a bounded monotonic + duration (`performance.now()` truncated to u32 ms, relative to + navigation start) — the latency gates' basis; `received_at` is ingest + time and is never used as event time. `flow` is closed: + `ssat | prebid | page_bids | direct | fallback | system`. +- Traces are navigation-scoped; attempt counts key on `attempt_id`. ### G2 — Render identity - Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte - (`publisher.rs:3355`; the PUC fetches `?uuid=`, `gpt/index.ts:1772`). - Markup bids: existing fallback chain. Renderer-only bids: server-minted - token `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, - cross-auction uniqueness probabilistic (36¹² ≈ 4.7×10¹⁸) and harmless - via scoping; TTL 15 min; one-time consumption. -- **Reservation store — one capacity, no unexpired eviction:** live - registrations and tombstones (consumed / stale / navigation-disposed - ids) share one bounded structure, **union capacity 320**; expired - entries are pruned; **unexpired entries are never evicted**; at - capacity, new registration is refused with `registry_full`. A late - prior-navigation bridge request always meets suppression until its id's - original TTL passes (preserving `gpt/index.ts:1527`). Test: >320 - registrations, then a late request for the oldest unexpired id. -- The client-Prebid path keeps Prebid's generated `adId`; one store - serves both paths. Non-APS cache-path byte-identity regression tests. - -### G3 — Runtime ABI under the IIFE build (exact-release) - -Every entry point is a self-contained IIFE with inlined imports -(`build-all.mjs:46`, `bundle.rs:23`) — imports never share state across -bundles (live defect: `core/context.ts:11` vs `permutive/index.ts:102`). - -- The kernel ships only in `tsjs-core`, publishes - `tsjs._internal = {release_id, registry}` once (window sentinel), - freezes `_internal` after boot, and constructs/registers core services - (event bus, beacon queue, sessions, slot registry, render engine) - during boot; integrations register integration-scoped services during - `install()`. -- **Exact release matching:** every registration carries `release_id` - (plugins via the §7.6 object API whose `release` is a build-generated - constant); `registry.get(name)` succeeds only on equality; mismatch - quarantines (`abi_mismatch` service / `bundle_partial` plugin) with a - console error. -- Stateful access only via the registry at call time; stateless helpers - may inline. -- **Boundary enforcement:** `import/no-restricted-paths` for layering - **plus** `no-restricted-properties`/`no-restricted-syntax` rules - catching member-expression access to `googletag`/`pbjs` through - `window`, `globalThis`, `self`, and local aliases outside `adapters/` - (`no-restricted-globals` cannot catch member expressions). Adapters are - the only access to **external ad-tech globals**; kernel and messaging - necessarily touch `window.tsjs`, listeners, and `postMessage`. + (`publisher.rs:3355`, `gpt/index.ts:1772`). Markup bids: existing + fallback chain. Renderer-only bids: server-minted token + `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, TTL 15 min, + one-time consumption. +- **Reservation store:** live registrations + tombstones (consumed / + stale / disposed) share one structure, **union capacity 320**; expired + entries pruned; **unexpired entries never evicted**; at capacity, new + registration refused with `registry_full`. Late prior-navigation + requests always meet suppression until original TTL (preserving + `gpt/index.ts:1527`). Test: >320 registrations, late oldest-id request. +- Client-Prebid keeps Prebid's `adId`; one store serves both paths. + Non-APS cache-path byte-identity regression tests. + +### G3 — Runtime ABI (exact-release) + +- IIFE-per-bundle with inlined imports (`build-all.mjs:46`, + `bundle.rs:23`); imports never share state (defect: + `core/context.ts:11` vs `permutive/index.ts:102`). Kernel only in + `tsjs-core`; `tsjs._internal = {release_id, registry}` frozen after + boot; core services constructed at boot; plugins via + `definePlugin({id, release, install})` with build-generated `release`; + `registry.get` succeeds only on equality; mismatch quarantines + (`abi_mismatch`/`bundle_partial`) loudly. +- **Boundary enforcement:** `import/no-restricted-paths` for layering, + plus a **custom scope-aware ESLint rule** for external-global access — + standard `no-restricted-properties`/`no-restricted-syntax` cannot + follow arbitrary aliasing, so the custom rule tracks member access to + `googletag`/`pbjs` through `window`/`globalThis`/`self` **and + same-file const aliases**; anything cleverer (cross-module smuggling) + is caught by review, and the claim is scoped to exactly that. Adapters + are the only access to external ad-tech globals; kernel/messaging + necessarily touch `window.tsjs` and `postMessage`. ### G4 — Render lifecycle -**G4a — Physical request cycles.** - -- **Intents, both classes, one causal queue.** Every observable - initiation — TS and wrapped publisher `display()`/`refresh()` — records - an intent in causal order, classified `ts | publisher`. Any - `display()` issued while initial load is disabled is **retired at - issuance regardless of caller** (GPT is caller-independent, §3.8) — it - never enters the matcher. Hindsight zero-request intents (`refresh()` - on a never-displayed slot) expire at 2 s with `intent_no_request`; - **any later request-capable intent — same class or opposite — - supersedes a pending uncertain intent immediately**, and if the - uncertain intent's request could still legitimately be in flight - (within its 2 s bound), the next `slotRequested` is ambiguous and the - slot quarantines. A stale no-op `refresh()` can therefore never steal - a later `display()`'s request in either direction, TS→TS, - publisher→publisher, or across classes. -- **Cycles** open only on `slotRequested`, matched to the causal queue - head; SRA batching yields one per slot per batch; cycles close on - `slotRenderEnded`; `responseIdentifier` deduplicates responses during - drain (it never attributes initiation). -- **Serialization:** at most one outstanding TS cycle per slot; one - queued TS replacement (later intents coalesce). -- **Attribution:** a `slotRenderEnded` is attributable iff exactly one TS - cycle is outstanding and no publisher/untracked request overlaps; - otherwise quarantine (`cycle_unattributable`), fail closed. -- **No timeout re-arm.** Physical cycle/drain state lives in the - RuntimeSession slot record; unissued intents are NavigationSession - children (cancelled by navigation disposal). A quarantined or stale - slot re-arms only on count-based drain, safe TS-owned - destroy/redefine, or page end. Timeouts emit diagnostics and never - restore attribution. Late stale events are matched and discarded - (`stale_navigation`). -- Deterministic-harness CI plus the release-gating real-GAM suite - (topologies enumerated in Appendix A.3). - -**G4b — Acknowledgement, per render path.** Four normative sequences; -each names its nonce producer, transport, authenticated acceptance -observation, cancellation, and deadlines (document 3 s, runner 10 s, -adm 5 s). All nonces are per-attempt 128-bit CSPRNG values minted by the -attempt owner; the kernel validates, in order: source ownership (§6.8 -walk), nonce, token, `nav_gen`, `refresh_gen` — before any transition or -notification. Navigation/supersession invalidates the nonce; late acks → -`stale_navigation`. - -1. **APS-PUC** (baseline transport): bridge mints the nonce; - MessageChannel into the renderer document (`ports.length` checks, - exact-key replies, one-shot `accepted` latch, port close — - `aps.rs:65-125`, `aps/render.ts:415-437`); the document posts - authenticated `renderer_document_loaded` then - `render_accepted | render_failed{reason}` to the top window. -2. **Generic ADM/cache-PUC:** the display renderer creates the sandboxed - adm frame with an injected reporter snippet that posts authenticated - `adm_document_loaded{nonce}` on document load; acceptance = that - message (the baseline merely appends an iframe with no observation). -3. **Direct APS** (`renderApsCreative`): the kernel is the frame parent; - the baseline parent-postMessage branch (`ports.length === 0`) is - already kernel-observed; same three messages, same validation. -4. **Direct ADM/cache:** as (2) with the kernel as parent. - -**G4c — Honest observations; one terminal state.** Inline-adm frames are -sandboxed `srcdoc` without `allow-same-origin` (`gpt/index.ts:510`) — -opaque; geometry proves nothing. Observations: `gam_nonempty`, -`gam_empty`, `gam_collapsed{action: resized | guarded, reason?}` -(observation and remediation separate), `renderer_document_loaded`, -`runner_loaded`, `runner_failed`, `adm_document_loaded`. **An attempt has -exactly one terminal state: `accepted | failed{reason} | no_bid | -cancelled`.** Post-acceptance runner failure is an observation only — -there is **no** `billing_outcome` event: no path has an honest producer -for a post-accept billing-failure claim (APS is excluded from -notifications and opaque frames offer no authenticated post-accept -signal), so the design does not pretend otherwise. No observation claims -paint; there is no `render_confirmed`. The baseline resize -(`gpt/index.ts:217`) is a sanctioned, guarded exception to the -no-foreign-DOM-mutation rule (authenticated source frame only; wrapper -only when both dimensions ≤ 1 px; anchor-ad and fixed/sticky guards). +**G4a — Physical request cycles.** Intents (both classes, one causal +queue): any `display()` under disabled initial load is retired at +issuance regardless of caller; hindsight zero-request intents expire at +2 s with `intent_no_request`; **any later request-capable intent — same +class or opposite — supersedes a pending uncertain intent**, and if the +uncertain one could still be in flight, the next `slotRequested` is +ambiguous → quarantine. Cycles open only on `slotRequested` (causal +head; SRA = one per slot per batch) and close on `slotRenderEnded` +(`responseIdentifier` dedups drain). One outstanding TS cycle per slot; +one queued replacement. Attribution requires exactly one outstanding TS +cycle and no overlap; otherwise `cycle_unattributable`, fail closed. **No +timeout re-arm** — re-arm only on count-based drain, safe TS-owned +destroy/redefine, or page end; unissued intents are NavigationSession +children; physical state is RuntimeSession. Deterministic-harness CI + +the release-gating real-GAM suite (Appendix A.3). + +**G4b — Acknowledgement, per render path.** Nonces are per-attempt +128-bit CSPRNG values; the kernel validates source ownership (§6.8), +nonce, token, `nav_gen`, `refresh_gen` before transitions or +notifications; navigation/supersession invalidates; late acks → +`stale_navigation`. Deadlines: document 3 s, runner 10 s, adm 5 s. + +1. **APS-PUC** (baseline transport): MessageChannel into the renderer + document (`ports.length` checks, exact-key replies, one-shot latch, + port close); the document posts authenticated + `renderer_document_loaded` then the accepted/failed result to the top + window. +2. **Generic ADM/cache-PUC:** **the acceptance observation lives in the + trusted owner, not the creative document** — the owner observes its + own iframe's `load`/`error` events and emits `adm_document_loaded`; + the nonce never enters the bidder realm (an injected reporter would + hand bidder-controlled code the acceptance credential and let it + trigger `burl` early — revision 8's reporter is withdrawn). +3. **Direct APS:** the kernel is the frame parent; the baseline + parent-postMessage branch is already kernel-observed. +4. **Direct ADM/cache:** as (2), owner-observed `load`/`error`. + +**G4c — Honest observations; one terminal event.** Observations: +`gam_nonempty`, `gam_empty`, `gam_collapsed{action: resized | guarded}`, +`renderer_document_loaded`, `runner_loaded`, `runner_failed`, +`adm_document_loaded`. **The terminal is one discriminated event: +`render_terminal{outcome: accepted | failed | no_bid | cancelled, +reason?}` — exactly one per `attempt_id`** (replacing separate +accepted/fail events the schema could not reconcile). A parent attempt +whose GAM cycle ends empty emits `render_terminal{failed, gam_empty}` +**before** its fallback child starts. Post-acceptance runner failure is +an observation only (no billing-failure event exists — no honest +producer). No observation claims paint. The baseline resize +(`gpt/index.ts:217`) stays a sanctioned, guarded exception. **G4d — Notifications.** APS carries neither `nurl` nor `burl` -(`aps.rs:839`; the AAX envelope excludes them; the integration guide -documents no generic APS beacons) — excluded entirely; the Amazon runner -lifecycle is unchanged. For carrying paths (PBS and other OpenRTB -providers): - -- Bind per flow, never selection or targeting (`ad_init.test.ts:1824`): - PUC — an owned, slot-and-ad-id-matched bridge claim; direct — - validated render start (the server must preserve and macro-expand - `nurl`/`burl` in `/auction` responses, `formats.rs:423` omits them; - the client must parse and https-validate them, `core/auction.ts:43` - drops them); fallback — attributed `gam_empty` immediately before - fallback render. -- `nurl` at bind; `burl` at `accepted`; **no retries**; idempotency key - `(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)` with the - normalized economic identity `(id_kind, id_value)` (direct attempts - without `hb_adid` use `bid_id`). -- **Observability without client cryptography:** the server mints an - opaque **`notif_id`** (12-char token, same generator as G2) per - notification-carrying bid and delivers it with the bid; every dispatch - emits `notification_sent{kind: nurl | burl, notif_id, result: -queued | failed}`. The browser computes no hashes (a client-held HMAC - key would break the pseudonymization boundary; a rotating token would - break stability across a gate window). -- **The duplicate-`burl` invariant is proven hermetically and - reconciled externally, not "proven" by lossy telemetry:** hermetic - tests pin exactly-once dispatch logic; production - `notification_sent` duplicates are a **detection alarm** (any - observed duplicate is a red gate); absence-of-duplicates is - established by billing reconciliation (GAM/SSP reports vs server-side - win counts) because sampled, best-effort telemetry cannot prove a - zero. - -**G4e — Fallback.** Opt-in -(`[auction].client_render_fallback = "renderer"`); renders only after a -terminal `gam_empty` unambiguously attributed to a TS cycle; ownership -does not gate it; publisher-initiated or unattributable cycles never -trigger it; timeouts never render. - -**G4f — Direct `/auction` lifecycle.** `RenderAttempt` keyed -`(trace_id, nav_gen, refresh_gen, slot)`; per-slot **latest-wins with -cancellation** (concurrent calls cancel the older attempt; -`request.ts:31` races today); generation checks before every DOM/beacon -effect; G4b sequences 3/4; G4d direct binds; navigation disposal; one -terminal state. **The auction client returns a discriminated result** — -`{ok: bids[]} | {error: "auction_timeout" | "network_error" | -"http_error" | "invalid_response"}` — replacing the baseline's -everything-is-an-empty-array collapse (§3.14); only a successfully -parsed response with no winner maps to `no_bid`. Public API: +(`aps.rs:839`) — excluded entirely. For carrying paths: bind per flow +(PUC: owned matched bridge claim; direct: validated render start — +server must preserve + macro-expand the URLs (`formats.rs:423` omits), +client must parse + https-validate (`core/auction.ts:43` drops); +fallback: attributed parent `gam_empty` immediately before child +render). `nurl` at bind; `burl` at `accepted`; idempotency key +`(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`. +**Dispatch mechanics (normative):** macros are expanded server-side +only; the client fires +`fetch(url, {method: "GET", mode: "no-cors", credentials: "omit", +redirect: "follow", referrerPolicy: "no-referrer", keepalive: true})`; +on synchronous failure the fallback is a detached `Image()` request; no +retries either way. Every dispatch emits +`notification_sent{kind, notif_id, result: queued | failed}` with the +**server-minted `notif_id`** (12-char token delivered with the bid). +Duplicates: hermetic exactly-once proof + production detection alarm + +billing reconciliation (lossy telemetry cannot prove a zero). + +**G4e — Fallback.** Opt-in; child attempt (own `attempt_id`, +`parent_attempt_id`, `flow = fallback`); renders only after the parent's +attributed `render_terminal{failed, gam_empty}`; publisher-initiated or +unattributable never triggers; timeouts never render. + +**G4f — Direct `/auction` lifecycle and the AuctionBatch.** A single +`/auction` fetch may serve several slots, so cancellation is +batch-aware: an **`AuctionBatch`** owns the fetch (its `AbortController`) +and the child `RenderAttempt`s. Supersession cancels **children +individually** (`render_terminal{cancelled}`); the fetch aborts only +when every child is dead or the batch times out or its navigation +disposes; every response bid is filtered through the **currently live** +child identity before any effect. Tests: partial overlap, full overlap, +timeout, navigation disposal, reversed responses. The auction client +returns a discriminated result — `{ok: bids[]} | {error: +"auction_timeout" | "network_error" | "http_error" | "invalid_response"}` +(§3.14); only a parsed empty response is `no_bid`. Public API: `tsjs.requestAds(options): Promise`, -`RequestAdsResult = {traceId, slots: [{slot, outcome: "rendered" | -"no_bid" | "failed" | "cancelled", reason?}]}`, settling when every slot -attempt is terminal. Reversed-response tests required. -**Fallback identity:** fallback is a **child attempt** — new -`RenderAttempt`, `flow = fallback`, carrying `parent_flow` (the -originating flow); the terminal `gam_empty` belongs to the parent -attempt under the parent's flow; the canonical view links parent and -child on `(trace_id, nav_gen, slot, refresh_gen)`. - -**G4g — Mid-attempt configuration and the commit point.** An attempt -snapshots configuration at creation. **Commit = the earliest -irreversible action** — the first of: notification dispatch (`nurl` at -bind), `bridge_response_sent`, or first DOM insertion. The -generation/kill-switch check runs **immediately before each** of those; -an attempt past commit runs to its terminal state; dispatched -notifications are never recalled. (Revision 7 put commit after the -`nurl` side effect; that ordering error is corrected.) +`RequestAdsResult = {traceId, slots: [{slot, outcome, reason?}]}`, +settling when every child attempt is terminal. + +**G4g — Mid-attempt configuration, honestly scoped.** Attempts snapshot +configuration at creation. **Commit = the earliest irreversible action** +(first of: notification dispatch, `bridge_response_sent`, first DOM +insertion), with the generation/kill check immediately before each. +**The kill switch's delivery is page-bound:** already-loaded pages have +no push channel, so live switch state travels only on responses the +page later fetches — page-bids and `/auction` responses carry +`ext.trusted_server.switches`, and new HTML carries current state. The +guarantee is therefore scoped: the switch affects attempts created +after the page received switch state; SSAT attempts on already-loaded +pages are unaffected by design, and the spec says so rather than +implying a live channel that does not exist. ### G5 — Deployment contracts -- Config `format_version` + manifest hash verification (§0). -- **Assets pre-materialized at release publication:** config is a - release-time input, so validated module vectors are known when the - release is built; concatenated bytes + hashes are produced then and - embedded; serving is lookup-only on every adapter (Fastly is - per-request, §3.13, so construction-time caching would be - meaningless). Unknown vector = release-build error; unknown hash = - `410 no-store`; exact match = `public, max-age=31536000, immutable`. -- **Internal route families — four:** renderer, client-events, - CSP-report, `/_ts/trace-auth`. All dispatch before auth/EC/publisher/ - integration filters (Fastly today runs EC setup and pre-route filters - first, `app.rs:709`); all methods and version prefixes reserved - locally (405 + `Allow` + `no-store`; unknown version 404 `no-store`; - never the publisher fall-through of `adapter-spin app.rs:804`); no - body/cookie/authorization forwarding. **Origin policy is per family**, - not universal: client-events and trace-auth require strict normalized - same-origin (scheme+host+port); the CSP route admits opaque/`null` - origins and authenticates by server-selected path identity plus abuse - limits; the renderer document is a public GET validated by - version/path only (it is loaded from sandboxed opaque contexts — - browser-origin authentication is impossible there by design). -- Ingest routes exist in all four adapters; Fastly has real sinks; - others accept-count-drop by contract (DR-5). -- §5.6 schemas deploy and validate before writers enable. +- Config verification per §0. Assets pre-materialized at release + publication (config is a release-time input); serving is lookup-only; + unknown vector = build error; unknown hash = `410 no-store`; exact + match = `public, max-age=31536000, immutable`. +- **Internal route families — four** (renderer, client-events, + CSP-report, `/_ts/trace-auth`): dispatch before auth/EC/publisher/ + integration filters (`app.rs:709` orders these wrong today); all + methods + version prefixes reserved locally (405 + `Allow` + + `no-store`; unknown version 404 `no-store`; no publisher fall-through, + `adapter-spin app.rs:804`); no body/cookie/authorization forwarding. + **Per-family origin policy:** client-events + trace-auth strict + normalized same-origin; CSP admits opaque/`null` origins with path + identity + limits; renderer is a public GET validated by version/path. +- Ingest routes in all four adapters; Fastly has real sinks; others + accept-count-drop (DR-5). §5.6 schemas deploy before writers. ## 5. Observability -### 5.1 Wire payload and per-event field matrix +### 5.1 Wire payload and field matrix ``` { v: 1, traces: [ { trace_id, auth, events: [ - { nav_gen, refresh_gen, seq, flow, auction_id?, t, ...fields } ] } ] } + { nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?, + auction_id?, t_rel_ms?, t, ...fields } ] } ] } ``` | `t` | fields | allowed `flow` | | -------------------------- | --------------------------------------------- | ----------------------- | | `bid_received` | slot, id_kind, source | render flows | | `targeting_set` | slot, id_kind | render flows | +| `attempt_started` | slot, source | render flows | | `bridge_request` | slot, id_kind, matched | ssat, prebid, page_bids | | `bridge_response_sent` | slot, source | ssat, prebid, page_bids | -| `render_attempt` | slot, source | render flows | -| `render_accepted` | slot, source | render flows | -| `render_fail` | slot, reason, source? | render flows | +| `render_terminal` | slot, outcome, reason?, source? | render flows | | `gam_nonempty` | slot | ssat, prebid, page_bids | | `gam_empty` | slot | ssat, prebid, page_bids | | `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? | ssat, prebid, page_bids | @@ -463,385 +402,326 @@ notifications are never recalled. (Revision 7 put commit after the | `runner_loaded` | slot | render flows | | `runner_failed` | slot, reason | render flows | | `adm_document_loaded` | slot | render flows | -| `fallback_start` | slot, parent_flow | fallback | +| `fallback_start` | slot | fallback | | `notification_sent` | slot, kind (`nurl`\|`burl`), notif_id, result | render flows | | `client_queue_overflow` | dropped (count) | system | -| `heartbeat` | probe_id, expected_seq | system | - -"Render flows" = `ssat | prebid | page_bids | direct | fallback`. -`source` on `render_fail` is **nullable**: absent for pre-source reasons -(`gpt_absent`, `pbjs_absent`, `slot_unresolved`, `intent_no_request`, -`abi_mismatch`, `registry_full`, `bundle_partial`); required otherwise. -The per-event/per-reason validity matrix is a generated artifact (§6.7). -Reason enum (closed): `renderer_document_no_load`, `runner_no_load`, -`runner_failed`, `descriptor_invalid`, `invalid_dimensions`, -`dimensions_out_of_range`, `bridge_id_mismatch`, `cycle_unattributable`, -`intent_no_request`, `stale_navigation`, `bridge_claim_timeout`, -`gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, -`pbjs_absent`, `bundle_partial`, `fallback_cancelled`, `abi_mismatch`, -`registry_full`, `currency_mismatch`, `auction_timeout`, -`network_error`, `http_error`, `invalid_response`, -`adm_document_no_load`. No client timestamp; the server stamps -`received_at`; ordering within a trace is `seq`. - -### 5.2 Transport and overflow - -`fetch(..., {keepalive: true, credentials: "same-origin"})` primary (§0 -affinity; the handler still derives no identity from cookies); -`pagehide` fallback `navigator.sendBeacon(url, new Blob([json], {type: -"application/json"}))`. Flush every 5 s and on -`visibilitychange`/`pagehide`. Queue bound 256 events. **Overflow never -enqueues into the full queue:** an out-of-band saturating counter -accumulates drops and one coalesced `client_queue_overflow{dropped}` is -materialized into the next flush. - -### 5.3 Signed trace authorization - -Format `v1....`; the `auth` field has its own -ingest bound of **256 bytes** (every other string keeps the 64-char -cap — a 43-char unpadded-base64url signature cannot fit 64 with its -prefix fields). - -- `kid`: `^[a-z0-9-]{1,16}$`; active + previous keys in the platform - secret store; keys ≥ 256-bit CSPRNG; previous keys retained ≥ 24 h - (≫ max token lifetime + skew). Missing key with the feature enabled → - startup/first-use failure, never silent. -- `exp`: canonical decimal unix seconds (no sign, no leading zeros); - ±60 s skew; ≤ 15 min future. -- `mode`: `sampled | unsampled | diagnostic | probe`. **`unsampled` is - the signed discard decision:** the client neither enqueues nor - transmits for it, and ingest rejects any group carrying it. `probe` - marks synthetic monitors (server-issued to probe runners); probe - traffic is never sampled out and is excluded from product metrics by - mode. -- `sig`: unpadded base64url of HMAC-SHA-256 (43 chars) over the - domain-separated, length-prefixed input `"ts-trace-auth-v1" || -u32be(len(origin)) || origin || u32be(len(trace_id)) || trace_id || -u32be(len(mode)) || mode || u64be(exp)`, strings UTF-8, `origin` = - externally visible scheme+host+port. Constant-time comparison. -- **Renewal preserves mode by verification, not trust:** - `GET /_ts/trace-auth` presents the current still-valid token in - `X-TSJS-Trace-Auth` (plus the trace header); the server verifies and - re-signs the same `trace_id` and `mode` with fresh `exp`. **The only - mode transition that exists is the diagnostic upgrade, a distinct - operation:** `POST /_ts/trace-auth/upgrade` presenting the current - token **and** a valid diagnostic credential; it re-signs with - `mode = diagnostic` and `exp = min(now + 15 min, credential expiry)`. - Plain renewal never changes mode. Renewal after expiry fails; the - client stops transmitting and counts locally. -- **Diagnostic credential:** issued `POST -/_ts/admin/diagnostic-credential` under the existing admin - authentication (CSRF: same-origin + custom header), format - `d1....`, absolute expiry ≤ 60 min, - origin-bound, **replayable short-lived bearer by design** (bounded by - expiry + origin binding; stated, not implied). **Exposure-minimized - transport:** the operator opens the page with `#tsdiag=`; - the synchronous bootstrap reads it, **immediately clears the fragment - via `history.replaceState`**, holds the credential **in memory only** - (RuntimeSession — never `sessionStorage`, which page scripts can - read), and exchanges it via the upgrade operation as soon as the trace - exists. Because initial HTML cannot see the fragment, `nav_gen 0` - starts `sampled | unsampled`; **when a pending `#tsdiag` fragment is - detected, the client buffers events locally without transmission - (bounded 256) until the upgrade resolves**, then flushes under - diagnostic mode — one-page-load diagnostic completeness holds without - delaying rendering. Forgery, wrong-origin, and replay-past-expiry - tests required. Validation is stateless HMAC — all four adapters. -- **Lazy cached initialization** applies to every secret-backed - component (trace-auth keys, diagnostic keys, sampling key, sinks): - first-use resolution with a cached result on request-bound platforms; - failure with the feature enabled is that feature's loud error path. +| `heartbeat` | probe_run_id, expected_seq, adapter, target | system | + +Render flows = `ssat | prebid | page_bids | direct | fallback`. +`attempt_started` carries the attempt's `t_rel_ms` baseline; the latency +metric is `render_terminal{accepted}.t_rel_ms − +attempt_started.t_rel_ms` per attempt. `source` on `render_terminal` is +nullable for pre-source reasons (`gpt_absent`, `pbjs_absent`, +`slot_unresolved`, `intent_no_request`, `abi_mismatch`, `registry_full`, +`bundle_partial`); the per-event/per-reason validity matrix is a +generated artifact (§6.7). Reason enum: `renderer_document_no_load`, +`runner_no_load`, `runner_failed`, `descriptor_invalid`, +`invalid_dimensions`, `dimensions_out_of_range`, `bridge_id_mismatch`, +`cycle_unattributable`, `intent_no_request`, `stale_navigation`, +`bridge_claim_timeout`, `gam_empty`, `no_render_source`, +`slot_unresolved`, `gpt_absent`, `pbjs_absent`, `bundle_partial`, +`fallback_cancelled`, `abi_mismatch`, `registry_full`, +`currency_mismatch`, `auction_timeout`, `network_error`, `http_error`, +`invalid_response`, `adm_document_no_load`. + +### 5.2 Transport, batching, and budgets (limiter-consistent) + +- Batches are capped by **both** 64 events **and** 12 KiB encoded + payload (headroom under the 16 KiB ingest cap); a flush drains the + queue as up to **4 sequential batches**. +- Cadence: flush every **10 s** and on `visibilitychange`/`pagehide`. + Worst-case honest traffic per tab: 6 flushes/min × ≤ 4 batches = ≤ 24 + requests/min transient, typically ≤ 6. +- **Budgets derived from that worst case:** client-side trace budget + ≤ 8 batches/min sustained (excess coalesces into the next flush); + ingest per-address budget **60 req/min, burst 120** (≈ 5 active tabs + plus pagehide bursts). The limiter can no longer reject honest + steady-state traffic by construction; tests cover sustained + single-tab, multi-tab (5), diagnostic pre-upgrade buffer flush, and + pagehide bursts. +- Transport: `fetch(..., {keepalive: true, credentials: +"same-origin"})`; `pagehide` fallback `sendBeacon(url, new +Blob([json], {type: "application/json"}))`. Queue bound 256; overflow + uses the out-of-band saturating counter + one coalesced + `client_queue_overflow` in the next flush (never enqueued into a full + queue). + +### 5.3 Signed authorizations + +**Trace authorization** `v1...[.].` (`auth` +ingest bound 256 bytes; all other strings 64): + +- `kid ^[a-z0-9-]{1,16}$`; keys ≥ 256-bit CSPRNG in the secret store; + previous keys retained ≥ 24 h; missing key with the feature enabled → + loud first-use failure. +- `exp` canonical decimal; ±60 s skew; ≤ 15 min future. `mode`: + `sampled | unsampled | diagnostic | probe`. **`dexp` is present iff + `mode = diagnostic`** — the immutable diagnostic ceiling, signed into + the token, set at upgrade to the credential's absolute expiry. + **Every renewal of a diagnostic token re-derives + `exp = min(now + 15 min, dexp)` and preserves `dexp`; past `dexp`, + renewal fails** — diagnostic access is bounded by the credential + forever, not just at upgrade (closing the indefinite-renewal hole). + Tests: renewal-before-expiry capped, repeated renewal to the ceiling. +- `sig` = unpadded base64url HMAC-SHA-256 over + `"ts-trace-auth-v1" || u32be(len(origin)) || origin || +u32be(len(trace_id)) || trace_id || u32be(len(mode)) || mode || +u64be(exp) [|| u64be(dexp)]`; constant-time compare; per-group + rejection at ingest. `unsampled` transmits nothing and is rejected if + carried. **Probe issuance protocol:** the probe runner authenticates + to `POST /_ts/admin/probe-authorization` (admin auth + CSRF) and + receives a batch of pre-signed probe-mode tokens tagged + `probe_run_id`; probe traffic is never sampled out and excluded from + product metrics by mode. +- Renewal: `GET /_ts/trace-auth` presenting the current still-valid + token in `X-TSJS-Trace-Auth`; re-signs same trace + mode (+ `dexp`). + **Diagnostic upgrade** is the sole mode transition: + `POST /_ts/trace-auth/upgrade` presenting token + credential. + +**Diagnostic credential** `d1....` — full byte-level +spec with vectors: `oh` = first 16 hex chars of SHA-256 of the +externally visible origin (scheme+host+port, UTF-8); `sig` = unpadded +base64url HMAC-SHA-256 over `"ts-diag-cred-v1" || u32be(len(origin)) || +origin || u64be(exp)`; same kid charset, key strength, rotation, and +≥ 24 h previous-key retention; ±60 s skew; absolute expiry ≤ 60 min; +constant-time compare; replayable short-lived bearer by design (bounded +by expiry + origin). Issued `POST /_ts/admin/diagnostic-credential` +(admin auth, CSRF: same-origin + custom header). Transport: `#tsdiag=` +fragment → read synchronously, cleared via `history.replaceState`, held +in memory only (RuntimeSession); pre-upgrade events buffer locally +(bounded 256) and flush after upgrade. Forgery, wrong-origin, +replay-past-expiry tests. + +Lazy cached initialization applies to every secret-backed component; +failure with the feature enabled is that feature's loud error path. ### 5.4 Ingest and rate limiting - `POST /_ts/client-events`: `application/json` only; no - `Content-Encoding`; responds `204`, `no-store`; never echoes input. - Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars - (`auth` ≤ 256 bytes); `trace_id ^[0-9a-f]{32}$`; integers `[0, 2³¹)`; - width/height `[0, 8192]`. Violations → drop-and-count with `204`. + `Content-Encoding`; `204`, `no-store`; never echoes input. Pre-parse: + body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 (`auth` ≤ 256 B); + `trace_id ^[0-9a-f]{32}$`; `attempt_id ^[a-z0-9]{8}$`; `auction_id` + canonical UUID; integers `[0, 2³¹)`; `t_rel_ms` u32. - Same-origin (client-events, trace-auth): `Sec-Fetch-Site: -same-origin` when present, else normalized `Origin` equality; absent - both → drop-and-count. -- **Rate limiting — adapter abstraction with declared semantics:** - trait `ClientEventLimiter`, key namespace per route family; intent - 10 req/min, burst 20 per client address. Axum: real in-process token - bucket, map ≤ 65,536 entries; Cloudflare/Spin: per-isolate/instance - best-effort, ≤ 4,096 entries; entry TTL 10 min with cleanup on access - plus periodic sweep — **capacity pressure rejects unseen identities, - but expired entries are always reclaimable, so saturation is bounded, - not permanent**; missing client address → shared `unknown` bucket at - 1 req/min. Fastly: the platform 60 s fixed-window counter at limit 20 - as a documented approximation; because its lookup and increment are - separate operations (§3.13), **overshoot under a synchronized burst - is bounded only by in-flight concurrency, and no numeric multiple is - claimed** — the synchronized-burst test (> 40 concurrent) documents - observed behavior, and a penalty-box follow-up is recorded if - observed overshoot is operationally unacceptable. Limiter - unavailable/errored → drop early with `204`. Trusted client address: - Fastly platform client IP; Axum rightmost `X-Forwarded-For` entry - after skipping exactly `trusted_proxy_hops` (absent config → socket - peer only); Cloudflare `CF-Connecting-IP`; Spin platform address. - `/_ts/trace-auth` and `/_ts/csp-reports/` carry their own - buckets with the same intent. - -### 5.5 Sinks, canonical views, per-sink monitoring - -- Stable event key `(publisher_domain, trace_id, seq)`. Canonical views: - `ts_client_events_v` (dedup: latest `received_at` per key) and - `ts_render_attempts_v` (attempt grain per G1). Dashboards and alerts - query canonical views only; raw-to-raw joins are forbidden (row - multiplication). -- The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`) - and cannot see downstream rejection — **each datasource gets its own - synthetic probe and freshness/loss query, per adapter write path**: - client-events via `heartbeat` events (mode `probe`, - `expected_seq` gaps = loss, lag = freshness); CSP via probe reports - to a reserved `policy_id = probe`; ops via a probe counter. A green - client-events heartbeat says nothing about the CSP or ops - credentials — hence three probes. Alert owner: release owner's - on-call. +same-origin` else normalized `Origin` equality; absent both → + drop-and-count. +- Limiter (per §5.2 budgets): trait `ClientEventLimiter`; Axum real + token bucket (60/min, burst 120), map ≤ 65,536; Cloudflare/Spin + best-effort ≤ 4,096/instance; TTL 10 min, cleanup on access + sweep; + at capacity reject unseen identities (expired always reclaimable); + unknown address → shared bucket 6/min; Fastly platform 60 s window at + limit 120 (approximation; overshoot bounded only by in-flight + concurrency — documented by the synchronized-burst test, no numeric + multiple claimed). Limiter unavailable → drop early with `204`. + Trusted address per adapter (Fastly platform IP; Axum rightmost XFF + after `trusted_proxy_hops`, absent → socket peer; CF + `CF-Connecting-IP`; Spin platform). Trace-auth and CSP routes carry + their own buckets (10/min, burst 20). + +### 5.5 Sinks, canonical views, per-sink authenticated probes + +- Event key `(publisher_domain, trace_id, seq)`; canonical views + `ts_client_events_v` (dedup) and `ts_render_attempts_v` (**keyed by + `attempt_id`**); the arm-union views stamp `deployment_pool` from the + write identity (§0). Dashboards/alerts query canonical views only. +- The Fastly sink is fire-and-forget (`tinybird.rs:153`) — + **per-datasource authenticated probes**: every probe row carries + `{probe_run_id, expected_seq, adapter, target}`; client-events via + `heartbeat` events under probe-mode tokens; CSP via probe reports to a + **secret-derived probe `policy_id`** (registered like any policy id, + not guessable — `policy_id = "probe"` would be publicly forgeable); + ops via an authenticated probe counter write. **Persistence gates are + scoped to sink-backed adapters** (DR-5); accept-count-drop adapters + get HTTP-parity gates only. Freshness = probe lag ≤ 5 min; loss = + `expected_seq` gaps < 0.1%; alert owner: release owner's on-call. +- **Alert-delivery drill:** a synthetic canary page injects a known + failure class at a known rate; the failure-detection alert must fire + within one hour — dashboards and alert latency are tested, not + assumed (Appendix A row). ### 5.6 Physical schemas (deployed before writers) - **`ts_client_events`**: `received_at DateTime64, publisher_domain -LowCardinality(String), release_id String, trace_id FixedString(32), -mode Enum(sampled|diagnostic|probe), nav_gen UInt32, refresh_gen -UInt32, seq UInt32, flow Enum(ssat|prebid|page_bids|direct|fallback| -system), auction_id Nullable(FixedString(36)), event Enum(§5.1), slot -Nullable(String), id_kind Nullable(Enum), matched Nullable(UInt8), -source Nullable(Enum), reason Nullable(Enum), action Nullable(Enum), -parent_flow Nullable(Enum), kind Nullable(Enum), notif_id -Nullable(FixedString(12)), result Nullable(Enum), dropped -Nullable(UInt32), probe_id Nullable(String), expected_seq -Nullable(UInt32)`. Sorting key `(publisher_domain, received_at, -trace_id, seq)`; TTL 30 days; own ingest token; sink batch cap 512; - startup validation of dataset + token when enabled; - sink-unavailable → accept-count-drop. +LowCardinality(String), release_id String, deployment_pool +Enum(canary|control), assignment_id Nullable(FixedString(32)), +trace_id FixedString(32), mode Enum(sampled|diagnostic|probe), nav_gen +UInt32, refresh_gen UInt32, seq UInt32, flow +Enum(ssat|prebid|page_bids|direct|fallback|system), attempt_id +Nullable(FixedString(8)), parent_attempt_id Nullable(FixedString(8)), +auction_id Nullable(UUID), t_rel_ms Nullable(UInt32), event Enum(§5.1), +slot Nullable(String), id_kind Nullable(Enum), matched Nullable(UInt8), +source Nullable(Enum), reason Nullable(Enum), outcome +Nullable(Enum(accepted|failed|no_bid|cancelled)), action +Nullable(Enum), kind Nullable(Enum), notif_id Nullable(FixedString(12)), +result Nullable(Enum), dropped Nullable(UInt32), probe_run_id +Nullable(String), expected_seq Nullable(UInt32), adapter +Nullable(Enum), target Nullable(Enum)`. Sorting key `(publisher_domain, +received_at, trace_id, seq)`; TTL 30 days; sink batch cap 512; startup + validation; sink-unavailable → accept-count-drop. - **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`): - add nullable `trace_id`, `mode`, `release_id`. Two added row types - with an explicit **`row_kind Enum(slot | totals | overflow)`** so - totals/overflow rows are valid instances (no publisher-controlled - sentinel strings; inapplicable fields nullable): - - `bid_drop {row_kind, provider Nullable(LowCardinality(String)) — -NULL on overflow, slot Nullable(String), reason + add nullable `trace_id`, `mode`, `release_id`; + `row_kind Enum(slot|totals|overflow)`; `bid_drop {row_kind, provider +Nullable(LowCardinality(String)), slot Nullable(String), reason Enum(AuctionDropReason), width Nullable(UInt16), height -Nullable(UInt16), count UInt32}` — cap 32 slot-rows/auction plus one - overflow row whose `count` = **actual dropped bids**, not compacted - rows; - - `selection_summary {row_kind, slot Nullable(String) — NULL on -totals, winner_source Nullable(Enum(mediator|direct|none)) — NULL on -totals, winner_provider Nullable(String) — NULL when winner_source -≠ a winner, candidates_direct UInt16, candidates_mediator UInt16, -dedup_hits UInt16, currency_rejected UInt16, provenance_invalid -UInt16, mediator_superseded UInt16}` — cap 8 slot-rows plus one - totals row that always survives truncation. Counters saturate at - `0xFFFF`/`0xFFFFFFFF`. - - **`AuctionDropReason` (closed, exhaustive over baseline - producers):** `script_rendering_disabled, invalid_dimensions, -dimensions_out_of_range, missing_render_source, -invalid_creative_url, unsupported_tagtype, -render_payload_too_large, unexpected_response_shape, -currency_mismatch, floor_rejected, provenance_invalid, -duplicate_demand, missing_bid_id, duplicate_bid_id, unknown_impid, -invalid_price, unsupported_media_type, creative_id_too_large, -empty_seatbid, renderer_extension_serialization_failed, -no_render_source, lost_to_higher_bid, overflow` — covering the - outcomes emitted at `aps.rs:740-929` and `formats.rs:408-419`; a - **compile-time exhaustiveness test maps every producer to the - enum**. +Nullable(UInt16), count UInt32}` (32 slot-rows + one overflow row whose + `count` = actual dropped bids); `selection_summary {row_kind, slot +Nullable(String), winner_source Nullable(Enum(mediator|direct|none)), +winner_provider Nullable(String), candidates_direct UInt16, +candidates_mediator UInt16, dedup_hits UInt16, currency_rejected +UInt16, provenance_invalid UInt16, mediator_superseded UInt16}` (8 + slot-rows + one totals row that survives truncation; saturating + counters `0xFFFF`/`0xFFFFFFFF`). + - **`AuctionDropReason` (closed, exhaustive over baseline producers, + one shared typed enum — no string literals):** + `script_rendering_disabled, invalid_dimensions, +dimensions_out_of_range, missing_render_source, invalid_creative_url, +unsupported_tagtype, render_payload_too_large, +unexpected_response_shape, currency_mismatch, floor_rejected, +provenance_invalid, duplicate_demand, missing_bid_id, +duplicate_bid_id, bid_id_too_large, empty_seatbid, +empty_seatbid_bids, unknown_impid, invalid_price, +unsupported_media_type, creative_id_too_large, +renderer_extension_serialization_failed, no_render_source, +lost_to_higher_bid, overflow` — covering `aps.rs:740-929` + (incl. `empty_seatbid_bids` at `:875`) and `formats.rs:408-419`; a + **compile-time exhaustiveness test maps every producer to the enum**. - **`ts_csp_reports`**: `received_at DateTime64, publisher_domain LowCardinality(String), release_id String, policy_id -LowCardinality(String), cohort LowCardinality(String), -directive_bucket Enum(script|style|frame|img|connect|font|media| -worker|other), source_bucket Enum(https_host_allowlisted|data|blob| -inline|eval|other), count UInt32`; sorting key `(publisher_domain, -received_at, policy_id)`; TTL 30 days; settings - `[telemetry.csp_reports] enabled, api_host, dataset, token_secret, -secret_store`. Ingest: pre-buffer body ≤ 8 KiB, ≤ 10 reports/request, - strings ≤ 256, nesting ≤ 4, both media types - (`application/csp-report`, `application/reports+json`) with separate - validators, unused fields discarded before logging, own limiter - bucket. +LowCardinality(String), cohort LowCardinality(String), directive_bucket +Enum(script|style|frame|img|connect|font|media|worker|other), +source_bucket Enum(https_host_allowlisted|data|blob|inline|eval|other), +count UInt32`; sorting key `(publisher_domain, received_at, policy_id)`; + TTL 30 days. Ingest: body ≤ 8 KiB, ≤ 10 reports/request, strings ≤ 256, + nesting ≤ 4, both media types with separate validators, unused fields + discarded, own limiter bucket. **Caps with values:** 10,000 + reports/hour/publisher and 1,000/hour/cohort; overflow increments the + `csp_overflow` ops counter (dropped reports counted, never parsed + further). - **`ts_ops_counters`**: `received_at DateTime64, publisher_domain LowCardinality(String), release_id String, counter Enum(renderer_requests|renderer_unknown_version|renderer_auth_blocked| ingest_accepted|ingest_dropped|ingest_rate_limited|abuse_flagged| -probe), value UInt64`; sorting key `(publisher_domain, received_at, -counter)`; TTL 90 days; settings `[telemetry.ops_counters]` (same - shape). -- **Sink plumbing:** one generic multi-target Tinybird sink trait; the +csp_overflow|probe), value UInt64`; sorting key `(publisher_domain, +received_at, counter)`; TTL 90 days. +- **Sink plumbing:** one generic multi-target Tinybird sink trait; `RuntimeServices` (`platform/types.rs:158`) gains handles for client-events, CSP, and ops targets beside the auction sink; each - target has its own dataset + token settings as above. + target has its own dataset + token settings. - **Settings:** `[telemetry.client_events] collection_enabled, sink_enabled, sample_rate, api_host, dataset, token_secret, -secret_store, max_body_bytes`; `[telemetry.trace_auth] secret_store, -active_kid, previous_kids, sampling_key_secret`; - `[telemetry.diagnostic] secret_store, active_kid`. +secret_store, max_body_bytes`; `[telemetry.csp_reports]` and + `[telemetry.ops_counters]` (same transport shape); + `[telemetry.trace_auth] secret_store, active_kid, previous_kids, +sampling_key_secret`; `[telemetry.diagnostic] secret_store, +active_kid`; `[telemetry.probe]` admin-issued run configuration. - APS parsing returns structured drop observations - `{reason, slot, width?, height?}` (`aps.rs:722` loses slot/values - today); >8192 → `dimensions_out_of_range` with dimensions omitted. + `{reason, slot, width?, height?}` (`aps.rs:722` loses slot/values); + > 8192 → `dimensions_out_of_range` unclamped. ### 5.7 Modes and SLIs -Production (sink-backed): deterministic keyed sampling (default 0.10). -SLIs: **pipeline availability** — per-sink probe freshness ≤ 5 min and -probe loss < 0.1% (fails during sink outages, alarmed); **failure -detection** — a failure mode affecting ≥ 1% of sampled render attempts -visible within one hour, at ≥ 10,000 sampled attempts/hour. Diagnostic: -credential-gated, unsampled, full stream + console mirroring + debug -envelopes (§2.5). +Production (sink-backed): deterministic sampling (0.10). SLIs: pipeline +availability (per-sink authenticated probe freshness ≤ 5 min, loss +< 0.1%); failure detection (≥ 1% of sampled render attempts visible +within one hour at ≥ 10,000/hour) — **verified by the injected-failure +alert drill**, not only by probe persistence. Diagnostic: +credential-gated, `dexp`-bounded, unsampled, full stream + console +mirroring + debug envelopes. ### 5.8 Server-side drop surfacing -Bounded structured summary whenever any bid is dropped; `bid_drop` and -`selection_summary` rows (§5.6); the initial-HTML `ts-debug` comment -carries the drop summary; page-bids and `/auction` carry the -tester-gated structured `debug` field. Startup warnings: APS + +Bounded structured summary whenever any bid is dropped; `bid_drop` + +`selection_summary` rows; `ts-debug` comment; tester-gated structured +`debug` on page-bids and `/auction`. Startup warnings: APS + `allow_script_creatives = false`; mediator + direct providers without -an explicit `winner_selection` (§6.1 hard error). +explicit `winner_selection`. ## 6. APS delivery fixes -### 6.1 Mediation — total order, no fictional identities - -Baseline defects: no forwarded candidate id; lossy last-write-wins -`(provider, slot, bidder)` field restoration (`adserver_mock.rs:95`); -arrival-order equal-price ties (`orchestrator.rs:827`); Prebid assumes -USD (`prebid.rs:2433`); APS stamps USD (`aps.rs:475`); no configured -currency. Replacement, identical in the synchronous and split -dispatch/collect paths via one shared helper: - -1. **Currency.** Required `[auction].currency` (ISO 4217). Every - provider parse validates its response currency; contract-implied - currencies are validated as implied — **APS enabled with a non-USD - configured currency is a startup error**, not a silent all-drop. - Mismatch → `bid_drop{currency_mismatch}`. -2. **Candidate identity.** `source_candidate_id` = - `(provider_name, upstream_bid_id)`; the upstream id is **required, - ≤ 64 chars, and unique per provider response — bids missing an id or - duplicating one are rejected** (`bid_drop{missing_bid_id | -duplicate_bid_id}`). No fingerprint fallback: a fingerprint over a - partial field set can merge economically distinct demand, and - OpenRTB requires bid ids — rejection is honest. `candidate_id` (wire - echo) is CSPRNG with in-auction collision retry and is never an - ordering key. Mediator-native bids get identities the same way from - the mediator's response. -3. **Mediator exchange.** Forwarded candidates carry - `ext.trusted_server.candidate_id`; the mediator echoes it (contract - for every mediator, `adserver_mock` included). Echoed id resolves → - the forwarded candidate, provenance `mediator`: **price is - authoritative from the mediator; every render-source and - notification field comes from the stored candidate; deal fields are - out of scope entirely** (no deal identity exists in the model). A - mediator bid whose any render-source field differs from the stored - candidate is reclassified mediator-native. An unresolvable echoed id - → that bid is discarded and counted (`provenance_invalid`); - mediator-native bids **and direct candidates remain eligible** — - fail-closed applies to the invalid claim, not the slot. -4. Floors filter both populations; echoed candidates remove their - direct twins (`dedup_hits`). -5. **Selection order (total, intrinsic):** decoded CPM desc → - provenance rank (mediator first) → `source_candidate_id` asc. - Arrival order can never matter. -6. **Strategy** required when mediator + direct providers coexist - (startup error if absent): `mediator_only` (timeout → no winners - unless `mediator_timeout_fallback = "direct"`) or - `merge_highest_cpm` (timeout → direct-only, reported). -7. Reporting: `selection_summary` slot rows + totals row (§5.6). +### 6.1 Mediation + +Eight rules, arrival-independent, one shared helper across both +lifecycles: (1) required `[auction].currency` (APS + non-USD = startup +error; Prebid validates at parse, `prebid.rs:2433`); (2) candidate +identity `source_candidate_id = (provider_name, upstream_bid_id)` with +the upstream id **required, ≤ 64 chars, unique** — missing/duplicate/ +oversized → `bid_drop{missing_bid_id | duplicate_bid_id | +bid_id_too_large}`, **no fingerprint fallback**; `candidate_id` (CSPRNG +wire echo, never an ordering key); (3) mediator echoes +`ext.trusted_server.candidate_id` — resolves → forwarded candidate, +provenance `mediator`, **price authoritative from the mediator, every +render-source and notification field from the stored candidate, deal +fields out of scope**; any render-source difference → mediator-native; +unresolvable echo → discarded + counted (`provenance_invalid`), +mediator-native and direct candidates stay eligible; (4) floors both +populations, echoes dedup twins; (5) **total order** decoded CPM desc → +provenance rank (mediator first) → `source_candidate_id` asc; (6) +required `winner_selection` (`mediator_only`: timeout → no winners +unless `mediator_timeout_fallback = "direct"`; `merge_highest_cpm`: +timeout → direct-only); (7) `selection_summary` reporting. ### 6.2 Dimensions -Exact size membership stays (`aps.rs:675`). The fix is visibility -(`bid_drop{invalid_dimensions, w, h}`) plus documentation ("request the -sizes you accept"); accepting unrequested sizes would conceal an -upstream protocol violation. +Exact membership (`aps.rs:675`); structured `bid_drop{invalid_dimensions, +w, h}`; "request the sizes you accept." ### 6.3 Script creatives -`allow_script_creatives` stays default-`false`; the consequence is loud -(§5.8). **DR-2's output is a deployment decision:** enable with explicit -sandbox/security approval, or accept a quantified maximum -excluded-demand share and gate Phase 3 on it. +Default `false`, loud (§5.8); **DR-2 is a deployment decision** (enable +with security approval, or accept a quantified excluded share and gate +Phase 3 on it). -### 6.4 Render identity +### 6.4 / 6.5 -As specified in G2 (token format, registry-union capacity, tombstones, -cache-path byte identity). - -### 6.5 Fallback - -As specified in G4e/G4a/G4f (attribution-gated child attempt; awaitable -renderer conversion precedes it; timeouts never render). +Render identity per G2; fallback per G4e (child attempt). ### 6.6 Renderer endpoint -- The static renderer document route registers **unconditionally in - every adapter** (the APS provider stays config-gated); startup - validation fails if an auth handler pattern covers it; §G5 isolation - rules apply. -- Path `/integrations/aps/renderer/v1`, embedded, served - `Cache-Control: public, max-age=31536000, immutable`; canary versions - `no-store` (or bounded below cohort lifetime); a **checked-in header - manifest per renderer version** freezes headers (CSP included) with - the bytes — a version's headers never change after publication. - Unknown versions → 404 `no-store`. -- Three-message acknowledgement per G4b sequence 1. -- Server route counters are aggregate (`ts_ops_counters`) — the nonce - rides the URL fragment and never reaches the server. -- **CSP rollout, three instruments:** discovery on the **currently - enforced** policy with reporting attached (report-only alone cannot - reveal what the enforced policy already blocks); **tightening** via - report-only; **relaxation** via a small enforced cohort on a - short-lived canary version, gated on runner acceptance rate, CSP - violation rate, and render-failure rate, with a kill switch; once - frozen, a new immutable `/v2` ships. **CSP reports are advisory** — - for opaque-origin reports the body-supplied document URL and policy - version are forgeable, so policy identity is encoded in the - **server-selected report path** (`/_ts/csp-reports/`); - bucketed aggregation only (§5.6 buckets, global and per-cohort caps); - never a sole automatic rollback signal. Browser capture on Chromium, - Firefox, and WebKit (CI is Chromium-only today, - `playwright.config.ts:16`; the matrix extends for this suite). +Unconditional route in every adapter (provider stays config-gated); +startup auth-pattern validation; `/integrations/aps/renderer/v1` +embedded, served `public, max-age=31536000, immutable` with a +checked-in per-version header manifest (headers frozen with bytes); +canary versions `no-store`; unknown version 404 `no-store`; +three-message ack (G4b-1); aggregate route counters in +`ts_ops_counters`; CSP rollout three instruments (enforced discovery / +report-only tightening / enforced-cohort relaxation on a short-lived +canary version, gated on runner acceptance, violation rate, render +failure, with a kill switch); **policy identity in the server-selected +`policy_id` path** (also the release/cohort carrier, §0); bucketed +aggregation only with the §5.6 caps; never a sole rollback signal; +three-browser capture (`playwright.config.ts:16`). ### 6.7 One descriptor schema -Wire truth is the tagged `BidRenderer` envelope (discriminator on the -enum, `types.rs:188-211`). A wire-schema crate/xtask (separate from -`trusted-server-js` — core already depends on it, `Cargo.toml:45`, so -the reverse edge would cycle) generates: the JSON-Schema artifact, the -TS structural parser, the ES5 inline validator fragment, the §5.1 -per-event/per-reason validity matrix, and shared fixtures — checked in, -staleness-gated. Semantic checks stay handwritten on both sides -(URL/origin policy, canonical base64, length bounds, the exact one-bid -AAX projection, cross-field equality). Unknown-field tolerance applies -only to the outer versioned descriptor; the decoded AAX envelope -remains an exact projection. A shared positive + adversarial corpus -(extra fields, wrong versions, oversized payloads, URL smuggling, -non-canonical base64) runs through the Rust validator, the generated TS -parser, and the generated inline fragment in CI. +Tagged `BidRenderer` envelope (`types.rs:188-211`); wire-schema +crate/xtask (no core↔js cycle, `Cargo.toml:45`) generates JSON-Schema, +TS parser, ES5 inline fragment, the §5.1 validity matrix, and fixtures; +staleness CI; semantic validators handwritten; outer tolerance only; +exact AAX projection; shared positive + adversarial corpus through all +three validators. ### 6.8 Bridge hardening -Processing order (normative; preserves the baseline defense that stops -propagation before source validation, `gpt/index.ts:1584-1637`): +Order (normative — preserving the baseline stolen-capability defense, +`gpt/index.ts:1584-1637`, **plus the read-only lookup the altered-id +signal needs**): 1. parse `e.data` (bare catch → return); -2. identify a TS-reserved ad id — **live registry or tombstone** (G2); -3. if TS-reserved: `stopImmediatePropagation()` before any validation — - a rejected foreign frame must not be answerable by Prebid's native - handler either; -4. validate source ownership via the bounded walk: known slot-root - `WindowProxy` map, the sender's own parent chain - (`event.source.parent`, …) to depth 5 — never scanning an - attacker-controllable frame tree; -5. validate nonce, token, `nav_gen`, `refresh_gen` (G4b); -6. respond, or refuse with `bridge_id_mismatch`. - -Non-TS ad ids are untouched. The stolen-token browser test asserts -**neither TS nor the native Prebid listener responds**; listener -registration order has a real-browser assertion. Renderer branches emit -the full §5.1 sequence with G4d notifications only on carrying paths. +2. **read-only source→active-slot lookup for every Prebid Request** — if + the resolved slot expects a different ad id, emit + `bridge_request{matched: false}` (the B1 signal) **without responding + and without suppressing native Prebid** (a truncated `hb_adid` is not + TS-reserved, so suppression would be wrong and the old order could + never produce the signal); +3. identify a TS-reserved ad id (live registry or tombstone, G2); +4. if TS-reserved: `stopImmediatePropagation()` before validation; +5. validate source ownership (known slot-root `WindowProxy` map; + sender's parent chain to depth 5; never scanning the frame tree); +6. validate nonce, token, `nav_gen`, `refresh_gen` (G4b); +7. respond, or refuse with `bridge_id_mismatch`. + +Non-TS ids are otherwise untouched. Stolen-token test: neither TS nor +native Prebid responds. Listener order: real-browser assertion. ## 7. TSJS target architecture @@ -854,35 +734,30 @@ services/ slots (registry+handoff), auction client, render engine, consen integrations/ gpt, prebid, aps, creative, datadome, … ``` -Enforced by the two G3 lint rule families. Dissolves the audited -inversions (`core/auction.ts`/`core/request.ts` → -`integrations/aps/render`; `gpt`/`prebid` → `aps`; `prebid` owning the -GPT refresh wrapper). Kernel imports nothing above it; adapters import -kernel only; services import kernel + adapters; integrations import -kernel + services, never each other; stateful services via the G3 -registry only. +Kernel imports nothing above it; adapters import kernel only; services +import kernel + adapters; integrations import kernel + services, never +each other; stateful services via the G3 registry only; enforced per +G3's two rule families. ### 7.2 Adapters -Per external global: `present | pending | timed_out`; `timed_out` is -non-terminal (late GPT/pbjs/CMP arrival transitions to `present` and -drains what is still valid); queued operations carry their own timeouts -and expire with disposition reasons. +`present | pending | timed_out` per external global; `timed_out` +non-terminal; queued operations carry their own timeouts and expire with +disposition reasons. ### 7.3 Slot registry service Kernel-owned; `WeakMap` + div-id index; -ownership (ts/publisher/adopted), adoption, handoff claims, responsive -resolution, the G4a causal intent queue (NavigationSession for unissued -intents) and cycle/drain state (RuntimeSession), targeting-key history. -No expandos on GPT objects (`__tsRenderGeneration`/`__tsRenderBid` -deleted). +ownership, adoption, handoff claims, responsive resolution, the G4a +causal intent queue (NavigationSession for unissued intents) and +cycle/drain state (RuntimeSession), targeting history. No expandos +(`__tsRenderGeneration`/`__tsRenderBid` deleted). ### 7.4 Final global surface (hard cutover) | Legacy surface (removed at cutover) | Final shape | | --------------------------------------- | --------------------------------------------------------------------- | -| `window.tsjs.que` | `window.tsjs.que` — unchanged, the one public queue | +| `window.tsjs.que` | `window.tsjs.que` — unchanged | | `globalThis.tscreative` | `tsjs.creative.*` | | `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | | `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) | @@ -892,84 +767,52 @@ deleted). | `tsjs._internal` | kernel registry (G3), frozen after boot | | (new, public) | `tsjs.definePlugin({id, release, install})` | -**Bootstrap correctness and transactional ownership:** every -server-injected initializer creates the container **idempotently, -field-wise** — `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}` -(the ad-slot script's `window.tsjs = {}` at `publisher.rs:3665` is -fixed). Ownership states: `unclaimed → installing → kernel | fallback`, -with an **owner generation** counter. The kernel installs wrappers -**inert** and flips them live at a single commit point; a throw before -commit runs the shared unwind inventory and marks `failed`. **The -watchdog path is race-free:** on a 10 s stuck `installing`, the -watchdog aborts the owner-generation-scoped `AbortController`, runs the -same shared unwind inventory to completion, and only then atomically -transitions `failed → fallback`; **every late kernel continuation and -disposer validates the owner generation** and self-discards on -mismatch — a resumed async installation can neither overwrite fallback -wrappers nor perform a stale commit. A bundle arriving after fallback -committed defers for the page (`bundle_partial`). Tests: throws -injected after each boot checkpoint **and** hung checkpoints that -resume after fallback claims ownership. +Bootstrap: field-wise idempotent init (`window.tsjs ||= {}; tsjs.que +||= []; tsjs.boot ||= {}`; `publisher.rs:3665`'s clobber fixed); +transactional ownership `unclaimed → installing → kernel | fallback` +with an owner-generation counter; kernel installs inert and flips at one +commit point; throws unwind to `failed`; the 10 s watchdog aborts the +owner-generation-scoped controller, completes the shared unwind, then +atomically transitions `failed → fallback`; late continuations and +disposers validate the owner generation and self-discard; a bundle +arriving after fallback committed defers (`bundle_partial`). Tests: +throws per checkpoint and hung-resume-after-fallback. ### 7.5 Messaging module All `postMessage` through one module: versioned envelopes, name -constants (the `'Prebid Request'` literal appears at six sites today; -the APS handshake existed in three copies), G4b nonces, §6.8 -validation. The minimal module (envelope + constants + validators used -by the bridge) lands in Phase 1; full call-site migration in Phase 4. - -### 7.6 Plugin lifecycle — transactional — and sessions - -`tsjs.definePlugin({id, release, install})` — object form; `release` is -the build-generated `release_id` constant; **there is no plugin-level -`dispose` hook** — disposal is exclusively `ctx.onDispose` -registrations, which have exactly-once reverse-order semantics -(revision 7's optional `dispose?` had no defined ordering and is -removed). `install(ctx): void | Promise`: - -- `ctx.signal` (aborted on quarantine/disposal); synchronous - `ctx.onDispose(fn)`; effects registered as they are made; - reverse-order unwind on throw/reject/abort; per-disposer exception - isolation; a disposer registered after disposal is invoked - immediately; pending late registrations capacity 16, bound 10 s → - `bundle_partial`; release mismatch quarantines before `install`. -- Sessions: `RuntimeSession` (page lifetime: bridge listener + - reservation store, history hook, pbjs subscriptions, adapters, beacon - queue, physical slot cycle/drain state, in-memory diagnostic - credential); `NavigationSession` (per navigation: trace + - authorization + renewal timer, render attempts, slot aliases, - unissued intents, targeting history); `RenderAttempt` (per G4a cycle - / G4f attempt). Each owns an enumerable disposal inventory; - navigation disposes NavigationSession children only. -- Error policy: no empty `catch` — handle, log with context, or emit a - disposition. The auction fetch gains timeout + `AbortController` and - the G4f discriminated result. -- **Console logging retained, not replaced:** every issue-surfacing - condition keeps or gains a `log.warn` carrying the same reason code - as its beacon event; `debug`-level delivery/security failures are - promoted to `warn`. +constants, G4b nonces, §6.8 validation. Minimal module in Phase 1; full +migration in Phase 4. + +### 7.6 Plugins and sessions + +`tsjs.definePlugin({id, release, install})`; **no plugin-level dispose +hook** — `ctx.onDispose` only, exactly-once reverse order. +`install(ctx): void | Promise` with `ctx.signal`, unwind on +throw/reject/abort, per-disposer isolation, disposer-after-disposal +invoked immediately, pending capacity 16 / 10 s → `bundle_partial`, +release mismatch quarantined before install. Sessions: `RuntimeSession` +(bridge listener + reservation store, history hook, pbjs subscriptions, +adapters, beacon queue, cycle/drain state, in-memory diagnostic +credential), `NavigationSession` (trace + auth + renewal timer, +attempts, aliases, unissued intents, targeting history), +`RenderAttempt`/`AuctionBatch`; enumerable disposal inventories. No +empty `catch`; console logging retained (paired `warn` with the beacon +reason; `debug`-level delivery/security failures promoted). ### 7.7 Bootstrap -`gpt_bootstrap.js` (495 ES5 lines duplicating handoff/initial-load/ -hydration logic, with the live `servicesEnabled` divergence) shrinks to -a queue-and-flags stub; the bundle replays recorded early calls on -install (browser specs cover replay timing); the no-bundle fallback -("ads render if the bundle fails", pinned by `gpt.rs:1174-1179`) is -**generated from the same TypeScript source** at build time, activated -per §7.4's transactional rules. +`gpt_bootstrap.js` shrinks to a queue-and-flags stub; the bundle replays +recorded calls; the no-bundle fallback is generated from the same +TypeScript source (pinned by `gpt.rs:1174-1179`), activated per §7.4. ### 7.8 GPT correctness carried with the restructure Unconditional early `slotRequested`/`slotRenderEnded` subscription -(replacing the `!servicesEnabled` gate, `gpt/index.ts:1091`; idempotent -recording); restore the #922 orphan-slot recovery and `updateRender` -enrichment (DR-3 decides #997 vs re-merge); `changeCorrelator: false` -on TS-initiated refreshes (configurable); `enableSingleRequest()` only -when GPT services are not already enabled; ambiguous responsive -resolution emits `render_fail{slot_unresolved}` alongside its console -warning. +(replacing `gpt/index.ts:1091`'s gate); restore #922/#997 (DR-3); +`changeCorrelator: false` (configurable); `enableSingleRequest()` only +when services are not already enabled; ambiguous responsive resolution +emits `render_terminal{failed, slot_unresolved}` alongside its warning. ### 7.9 Decomposition targets @@ -978,324 +821,255 @@ warning. | `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | | `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics | | `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | -| `core/trace.ts` (model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | +| `core/trace.ts` (model + UI) | `services/trace` + `integrations/trace_overlay` | | `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal | ### 7.10 Performance (reproducible) -- **Dedicated workflow** pinned `runs-on: ubuntu-24.04` (browser CI is - `ubuntu-latest` today, `integration-tests.yml:155`) inside a pinned - container image digest; browser = the lockfile-resolved - `@playwright/test` build with its browser revision recorded in the - baseline artifact (the manifest is a caret range, - `browser/package.json:10` — lockfile + recorded revision are - authoritative); compressors pinned by version in the container; - deterministic flags (`gzip -9 -n`, `brotli -q 11`). -- **Module vectors enumerated:** minimal = `[core]`; reference = - `[core, creative, gpt, prebid, datadome]`; maximal = all 13 - discovered modules. Budgets: raw/gzip/Brotli per bundle per vector vs - checked-in baselines (`perf/baselines/*.json`; updates are reviewed - diffs recording image/browser/tool versions; a baseline update is - invalid if any pinned component differs); +5% bytes. -- **Browser timing:** marks `performance.mark("tsjs:bids-script")` - (emitted by the injected bids script) to - `performance.mark("tsjs:first-display")` (emitted by the adapter - wrapper at the first `display()`/`refresh()` dispatch); reference - fixture page; warm HTTP cache; all resources local; 5 warm-ups - discarded, 50 samples; p90 = nearest-rank; gate p90 ≤ baseline × - 1.10; inconclusive (3-run agreement worse than 5%) → one rerun, then - fail. **Maximal-vector peak JS heap ≤ baseline × 1.10.** -- **Server benchmark:** the G5 lookup path; 100 warm-ups, 1,000 - iterations; median and p90; one-sided ≤ baseline × 1.10; 3 - consecutive runs within 5% or inconclusive (rerun, never pass). +Pinned workflow (`runs-on: ubuntu-24.04` — browser CI is `ubuntu-latest` +today, `integration-tests.yml:155` — inside a pinned container digest); +lockfile-resolved Playwright with recorded browser revision +(`browser/package.json:10` is a caret range); pinned compressors +(`gzip -9 -n`, `brotli -q 11`). Vectors: minimal `[core]`; reference +`[core, creative, gpt, prebid, datadome]`; maximal all 13. Budgets vs +checked-in baselines (+5% bytes; baseline records image/browser/tool +versions and is invalid if any differ). Browser timing: +`performance.mark("tsjs:bids-script")` → +`performance.mark("tsjs:first-display")`; reference fixture; warm cache; +local resources; 5 warm-ups, 50 samples, nearest-rank p90 ≤ baseline × +1.10; inconclusive (3-run agreement > 5%) → one rerun, then fail. **Peak +JS heap, reproducibly:** via the Playwright CDP session — +`HeapProfiler.collectGarbage` then `Runtime.getHeapUsage`, sampled at +five fixed points (post-boot, post-adInit, post-first-render, +post-refresh, post-SPA-navigation) on the maximal vector; metric = max +sample; gate ≤ baseline × 1.10; same rerun rule. Server benchmark: the +G5 lookup path; 100 warm-ups, 1,000 iterations; median + p90 one-sided +≤ baseline × 1.10; 3-run 5% agreement or inconclusive. ### 7.11 Toolchain -TypeScript floor to the resolved 5.9 line (lockfile resolves 5.9.3 -under the stale `^5.5.4` manifest). **Release-gating flags:** `strict`, -`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, +TypeScript floor to the resolved 5.9 line; release-gating flags +`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `noImplicitOverride`, -`useUnknownInCatchVariables`. **Gate command (checked-in npm script -`typecheck`):** +`useUnknownInCatchVariables`; checked-in npm script `typecheck`: ``` cd crates/trusted-server-js/lib && npx --no-install tsc -p tsconfig.json --noEmit ``` -(runs where the pinned compiler is installed; `--no-install` guarantees -the lockfile-resolved binary). Dev toolchain bumps as individual -CI-gated PRs with changelog review (this library monkeypatches -`fetch`/`sendBeacon`/DOM prototypes); `prebid.js` excluded from casual -bumps (runtime Prebid is the manifest-locked external bundle; npm pin -and deployed bundle version documented together); monthly review. +Dev toolchain bumps as individual CI-gated PRs; `prebid.js` excluded +from casual bumps; monthly review. ## 8. Rollout -Single-release state machine per §0 (authenticated sticky-cohort -affinity; infrastructure-attributed cohorts). The normative gates table -is Appendix A; every gate names a **checked-in artifact** (versioned -Tinybird pipe under `tinybird/pipes/`, script under `scripts/gates/`, -or workflow under `.github/workflows/`) — prose never substitutes for -an executable reference. Threshold changes require reviewed decision -records. - -**Phase 0 decision records** (owner, evidence, deadline, explicit -go/no-go): DR-1 mediator presence (gates §6.1's Phase-3 scope); DR-2 -script creatives — a **deployment decision** (§6.3); DR-3 #997 vs -re-merge (#922 restoration path); DR-4 mediator candidate-id echo owner -and timeline (`merge_highest_cpm` is config-blocked until delivered); -DR-5 non-Fastly sinks (splits Phase-2 gates). - -- **Phase 0 — Identity, schemas, toolchain, decisions.** Release-time - asset materialization; `format_version` + config-hash verification; - §5.6 schemas deployed writer-off; toolchain floors; dead expando - deletion; §5.8 drop surfacing; the five DRs; the gate artifacts - themselves (pipes/scripts/workflows). -- **Phase 1 — Kernel, sessions, minimal messaging, cycle registry, - transactional bootstrap ownership.** -- **Phase 2 — Trace + beacon.** Issuance on all three paths + renewal + - diagnostic upgrade + probe mode; four-adapter ingest incl. - `/_ts/trace-auth`; per-sink probes. Gates split per DR-5: HTTP parity - (all adapters) vs persistence (sink-backed). -- **Phase 3 — APS delivery.** Schema crate + corpus; §6.1 with required - `winner_selection` + `[auction].currency`; render token + reservation - store; renderer route + three-message ack + CSP report route; §6.8; - G4a–G4g; `notification_sent` with server-minted `notif_id`; fallback; - DR-3 restoration; correlator + SRA fixes. -- **Phase 4 — Structure.** Full layering + both lint families; plugin - lifecycle; adapters; full slot registry; full messaging migration; - final namespace; four-flow behavioral parity. -- **Phase 5 — Decomposition + cutover.** File splits; script-guard - consolidation; bootstrap stub + generated fallback (error/hang/ - arbitration tests); four-flow parity rerun; **the full Phase-3 - statistical canary/control gates and the real-GAM suite repeat on the - exact immutable release candidate** before router weight rises beyond - the low-weight canary; then weight-up, purge, 24 h monitored window. - -**Statistical method (normative for A.1 production gates):** -populations are **sampled traces only** — diagnostic traffic is -operator-selected and failure-enriched, so it is reported separately -and never enters a statistical gate. Assignment unit = the sticky -cohort token (browser session); cohorts randomized at HTML request; -stratification by publisher and slot. Non-inferiority gates use -**one-sided 95% confidence bounds on the relative difference** -(canary/control − 1 ≥ −2% for fill and billing-per-1,000-attempts; -canary/control − 1 ≤ +2% for p95 latency); improvements always pass. -Floors are **per flow per arm** (Appendix A); flows that cannot reach -their floor in the window (direct, fallback at low adoption) are gated -hermetically and by the real-GAM suite instead of statistically — a -rare flow never permanently blocks rollout, and a statistical gate that -cannot reach its floor is **inconclusive** (extend once, then Hold). -`cycle_unattributable` is divided by **all TS request cycles that were -candidates for attribution** — the failures live in their own -denominator. Missing telemetry counts as failure. Billing reconciliation -(§G4d) runs alongside as the authoritative duplicate check. +Single-release state machine per §0. **Statistical method:** sampled +traces only (diagnostic reported separately); **randomization unit = +`assignment_id`** (minted inside the affinity token, §0; persisted on +client-event rows), so attempts cluster by session; the estimator is a +**checked-in cluster bootstrap** (`scripts/gates/estimator.py`): +resample assignment ids, 2,000 resamples, fixed seed recorded in the +gate artifact, strata (publisher × slot) weighted by control-arm +traffic share; one-sided 95% confidence bounds on **relative +differences**; each gate is an independent go/no-go (no cross-gate +multiplicity correction — stated). Missing telemetry counts as failure. +Per-flow floors; rare flows (direct, fallback) gate hermetically + +real-GAM, never statistically. `cycle_unattributable` divides by all +attribution-candidate TS cycles. **Billing cohort dimension in external +reports:** TS-driven GAM requests set a `ts_arm=` key-value, +making the arm a reportable GAM dimension for reconciliation. + +**Phase 0 decision records:** DR-1 mediator presence; DR-2 script +creatives (deployment decision); DR-3 #997 vs re-merge; DR-4 +candidate-id echo owner (`merge_highest_cpm` config-blocked until +delivered); DR-5 non-Fastly sinks (splits Phase-2 gates, scopes +persistence gates). + +Phases: **0** identity/schemas/toolchain/DRs + gate artifacts +(pipes/scripts/workflows) + observation-only control build definition; +**1** kernel/sessions/minimal messaging/cycle registry/transactional +bootstrap — Phase-1 gates are **hermetic** (in-page counters; beacon +transport does not exist until Phase 2); **2** trace + beacon + renewal + +- diagnostic upgrade + probe issuance + four-adapter ingest + per-sink + probes + the alert drill; **3** APS delivery (schema crate + corpus; + mediation; render token + reservation store; renderer route + + three-message ack + CSP route; §6.8; G4a–G4g incl. AuctionBatch; + `notification_sent`; fallback; DR-3 restoration; correlator + SRA); + **4** structure (layering, plugins, adapters, registry, messaging, + namespace; four-flow parity); **5** decomposition + bootstrap stub + + parity rerun + **full Phase-3 statistical and real-GAM gates repeated + on the exact immutable RC** (attested per A.3) before weight-up, then + cutover per §0. ## 9. Test acceptance matrix -Hermetic CI blocks PRs; the real-GAM suite is release-gating per -Appendix A.3. - -| Area | Must cover | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Request cycles | intent-vs-request; disabled-initial-load `display()` retired for **any caller**; publisher-display → TS-refresh and TS-noop-refresh → TS-display supersession (same-class stealing); SRA; `intent_no_request`; overlap quarantine; no timeout re-arm; stale discard; real-GAM overlap | -| Ack protocol | all four G4b sequences incl. the adm reporter; three-message APS sequence; five-field validation; SSAT/client-Prebid/SafeFrame; stale/replayed acks; acks after navigation disposal; per-path deadlines | -| Bridge security | propagation stopped before validation; neither TS nor native Prebid responds to stolen ids; prior-navigation ids suppressed via the reservation store after disposal; bounded parent-chain walk; listener order (real browser) | -| Render semantics | binds per flow (never targeting); `burl` at accepted; attempt idempotency; `notification_sent{notif_id}` per dispatch; duplicate-detection alarm; accepted-but-blank honesty; `gam_collapsed{action}` emission + guarded resize | -| Direct `/auction` | trace header + `ext.trusted_server.trace` echo; discriminated auction-client errors (timeout/network/http/invalid vs `no_bid`); per-slot latest-wins with reversed responses; generation checks; `RequestAdsResult` settlement; server preserves + expands `nurl`/`burl`, client validates | -| Fallback | child-attempt identity with `parent_flow`; renders only on attributed parent `gam_empty`; publisher-initiated never; timeout never renders; SPA cancellation; kill-switch pre-commit cancellation (commit = earliest irreversible action) | -| Mediation | required-unique upstream ids (`missing_bid_id`/`duplicate_bid_id` rejection — no fingerprint); `candidate_id` echo; arrival-order shuffle invariance; authoritative-field rules (repricing kept; any render-source difference → native); provenance fail-closed scope; strategy-specific timeouts; both lifecycles; APS + non-USD startup error | -| Render token | format/CSPRNG/retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scope; union capacity 320 with `registry_full`; >320 then late oldest-id suppressed | -| Trace auth | auth ≤ 256 B; encoding vectors (kid charset, canonical exp, u32/u64 BE prefixes, unpadded base64url); expiry/skew/max-future; renewal preserves mode via token presentation; renewal-after-expiry fails closed; previous-key retention; deterministic sampling (same trace → same mode concurrently; exact u64 threshold algorithm) | -| Diagnostic | credential issuance under admin auth + CSRF; fragment cleared via `replaceState`; in-memory-only storage; upgrade as the sole mode transition; auth `exp` capped at credential expiry; pre-upgrade local buffering then diagnostic flush; forgery/wrong-origin/replay-past-expiry | -| Trace-auth route | four-adapter parity; wrong-method 405; dispatch before filters; no forwarding; own limiter bucket | -| Affinity | opaque token validation (forged/expired/retired → control + reissue); coherence for HTML, assets, APIs, **beacons, CSP reports**; cache-key normalization; rollback reassignment | -| Join keys | `auction_id` echo on all three paths; attempt-grain join uniqueness under repeated same-slot auctions; infrastructure cohort attribution (per-pool tokens) | -| Funnels | `flow` set per path incl. `system`; per-flow expected-stage conformance; heartbeat/overflow excluded from render denominators | -| Beacon | joins on all three issuance paths; per-trace grouping; seq gaps; duplicate fetch/pagehide deduped in the canonical view; overflow coalescing without recursion; ingest abuse incl. absent Origin; sendBeacon Blob type; `credentials: same-origin` with identity-free handling | -| Ingest/limits | per-adapter limiter semantics as declared; TTL reclaim under saturation; unknown-address bucket; Fastly synchronized-burst behavior documented (> 40 concurrent); XFF hop selection; fail-closed 204 | -| Internal routes | wrong-method 405 + `Allow` + `no-store` on every adapter; unknown version 404; no publisher fall-through; dispatch before auth/EC/filters; no forwarding; per-family origin policies | -| CSP | both media types with separate validators; opaque/null-origin admission; policy-id path identity (forged body URL/version ignored); bucketed aggregation with caps; three-browser capture; per-version frozen header manifest | -| Schema | staleness; adversarial corpus through Rust + generated TS + generated inline fragment; outer tolerance vs exact AAX projection; generated validity matrix; **compile-time exhaustiveness of `AuctionDropReason` over all producers** | -| Runtime ABI | one kernel under concatenation; exact-release verdicts; late registration; failure isolation; object-form `definePlugin` release check | -| Plugins | partial-install unwind; async rejection; abort while pending; disposer-after-disposal; per-disposer isolation | -| Bootstrap | field-wise idempotent init (ad-slot script no longer clobbers); inert-install + commit flip; throw after each checkpoint unwinds; **hung checkpoint resuming after fallback self-discards via owner generation**; fallback-then-late-bundle deferral | -| Lifecycle | `timed_out → present`; session disposal inventories; unissued intents cancelled by navigation disposal; boot container consume/freeze/delete; final-namespace smoke (`tsjs.que`, `tsjs.creative`, async `requestAds`, `definePlugin`) | -| Delivery | unknown hash 410 `no-store`; exact-match immutable with full directive; release-time vector materialization (unlisted vector = build error); config-hash verification failure; cutover rehearsal (weight, purge, rollback) | -| Sinks/monitoring | per-datasource probes (client-events heartbeat, CSP probe policy-id, ops probe counter) per adapter write path; canonical-views-only enforcement (raw-join multiplication test); `publisher_domain` naming | -| Failure injection | Amazon runner redirect/hang/CSP block/script error → distinct §5.1 outcomes; EC/filter failure before renderer dispatch | -| Adapter parity | ingest, CSP-report, trace-auth, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | -| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness per §2.5; kill-switch snapshot semantics | -| Perf | marks present; three vector contents; heap budget; inconclusive-rerun policy; pinned-environment baseline validity | -| Lint | member-expression access to `googletag`/`pbjs` via `window`/`globalThis`/`self`/aliases caught outside adapters | +Hermetic CI blocks PRs; the real-GAM suite is release-gating (A.3). + +| Area | Coverage | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Attempts | `attempt_id` uniqueness; parent/child fallback linkage; exactly one `render_terminal` per attempt; parent `failed{gam_empty}` precedes `fallback_start` | +| Terminal model | discriminated outcomes incl. `no_bid`/`cancelled`; per-reason `source` nullability; validity-matrix conformance | +| Request cycles | disabled-initial-load `display()` retired for any caller; TS-noop-refresh → TS-display and publisher variants (same-class supersession); SRA; `intent_no_request`; overlap quarantine; no timeout re-arm; stale discard; real-GAM overlap | +| Ack per path | four G4b sequences; APS three-message; owner-observed ADM `load`/`error` with **nonce never in bidder realm**; bidder-synthesized message ignored; early-`burl` attempt fails; per-path deadlines; stale/replayed; after-disposal | +| Bridge signal | truncated/replaced `hb_adid` → `bridge_request{matched:false}` with no response and native Prebid untouched; stolen TS ids fully suppressed (neither TS nor native responds); listener order | +| AuctionBatch | multi-slot partial/full supersession; fetch aborts only when all children dead; stale-bid filtering through live child identity; timeout; navigation disposal; reversed responses; discriminated auction-client errors | +| Diagnostic | `dexp` ceiling — renewal-before-expiry capped, repeated renewal to ceiling then failure; credential byte-level vectors; fragment clearing; in-memory storage; pre-upgrade buffering then flush; forgery/wrong-origin/replay | +| Transport/limits | batch caps (64 events AND 12 KiB); ≤ 4 batches/flush; sustained single-tab, 5-tab, diagnostic flush, pagehide burst inside the 60/120 budget; overflow coalescing; Fastly synchronized-burst documented; unknown-address bucket | +| Affinity | token vectors (format, rotation, constant-time); state-dependent defaults (canary vs post-cutover reassignment; no stale-cookie stragglers); coherence for HTML/assets/APIs/beacons; **CSP affinity via `policy_id` path, cookie-less renderer reports** | +| Arms/measurement | observation-only control build emits comparable sampled telemetry (its zero-behavior-change gated by hermetic parity vs baseline); arm-specific datasources; union view stamps `deployment_pool`; `assignment_id` on rows; cluster-bootstrap fixture; GAM `ts_arm` key | +| Latency/fill | `t_rel_ms` monotonic bounds; `attempt_started`→`render_terminal` durations; per-gate numerator/denominator queries (A.1); named source tables/joins | +| Joins | `Nullable(UUID)` type equality; auction-level vs slot-level vs bid-level canonical joins; raw-join multiplication rejected | +| Probes | authenticated probe rows (`probe_run_id`/`expected_seq`/`adapter`/`target`) per datasource; secret-derived CSP probe `policy_id` unforgeable; probe issuance protocol; persistence gates scoped to sink-backed adapters | +| Alerting | injected-failure drill: alert fires ≤ 1 h | +| Kill switch | switch state via HTML and response extensions; attempts created after delivery honor it before each irreversible action (incl. before `nurl`); SSAT-on-stale-page exemption documented and tested | +| Drop enum | `empty_seatbid_bids` + `bid_id_too_large` mapped; shared typed enum across producers; compile-time exhaustiveness | +| RC attestation | real-GAM workflow consumes the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}` and emits it in the attested output; gates parameterized by (release, pool, epoch); RC re-canary inherits each row's window | +| Config/affinity | `config_hash` SHA-256 over exact bytes verified at startup (mismatch = fail); affinity HMAC vectors + rotation + constant-time | +| Heap | CDP procedure at the five fixed points; GC before sample; rerun rule | +| Lint | custom scope-aware rule catches `window`/`globalThis`/`self` member access and same-file aliases outside adapters (claim scoped to these shapes) | +| Notifications | dispatch mechanics (no-cors GET, no-referrer, keepalive; `Image()` fallback; server-side macro expansion only); `notif_id` emission; duplicate alarm + reconciliation; hermetic exactly-once | +| Mediation | required-unique bounded upstream ids (`missing`/`duplicate`/`bid_id_too_large`, no fingerprint); `candidate_id` echo; arrival-order shuffle invariance; authoritative-field rules; provenance fail-closed scope; strategy timeouts; both lifecycles; APS + non-USD startup error | +| Render token | format/CSPRNG/retry/TTL/one-time; scope; union capacity 320 with `registry_full`; >320 then late oldest-id suppressed | +| Trace auth | auth ≤ 256 B; encoding vectors; expiry/skew/max-future; renewal preserves mode; renewal-after-expiry fails; previous-key retention; deterministic sampling (exact u64 threshold; same trace → same mode concurrently) | +| Internal routes | wrong-method 405 + `Allow` + `no-store`; unknown version 404; no fall-through; dispatch before filters; no forwarding; per-family origin policies | +| CSP | both media types; opaque/null origin; policy-id path identity (forged body ignored); bucket caps + overflow counter; three-browser capture; per-version frozen header manifest | +| Schema | staleness; adversarial corpus ×3 validators; outer tolerance vs exact AAX; generated validity matrix | +| ABI/plugins/boot | one kernel; exact-release verdicts; object-form release check; partial-install unwind; abort-pending; disposer-after-disposal; hung-resume self-discard; fallback-then-late-bundle deferral | +| Lifecycle | `timed_out → present`; disposal inventories; unissued intents cancelled by navigation; boot consume/freeze/delete; final-namespace smoke | +| Delivery | unknown hash 410 `no-store`; exact-match immutable; release-time vector materialization (unlisted = build error); cutover rehearsal | +| Adapter parity | ingest, CSP-report, trace-auth, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | +| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness; kill-switch snapshot semantics | +| Perf | marks present; three vectors; heap CDP; inconclusive-rerun; pinned-environment baseline validity | ## 10. Alternatives considered -1. Patching APS point-failures without telemetry — rejected: four - correct fixes have not produced reliable ads. -2. Always direct-render APS (skip GAM/PUC) — rejected: changes GAM - reporting/pacing unilaterally; kept as the attributed-`gam_empty` - fallback. -3. Single module graph / shared chunks now — rejected for this release: - changes the delivery pipeline while everything else changes; - successor option behind the same registry surface. -4. Full rewrite in one branch without phases — rejected: the - browser-spec safety net is thinnest exactly where behavior changes. -5. Dropping the ES5 bootstrap — rejected: loses the pinned no-bundle - guarantee; the generated fallback keeps it. -6. Timeout-triggered fallback rendering — rejected: GPT requests cannot - be cancelled; timeout racing a late fill can double-render and - double-bill. -7. Timeout-based quarantine re-arm — rejected (recreates the stale-event - bug). -8. N/N−1 compatibility machinery — removed by the §0 policy decision. -9. Client-computed notification hashes — rejected (no key without - breaking the pseudonymization boundary); server-minted `notif_id`. -10. Fingerprint identities for id-less bids — rejected (can merge - distinct demand); rejection with closed reasons instead. -11. Plain readable cohort cookie — rejected (dark-pool opt-in + - cache-cardinality abuse); opaque authenticated token. -12. `billing_outcome` event — removed (no honest producer exists). +Revision 8's twelve rejections stand (patch-without-telemetry; +always-direct-render; shared chunks now; big-bang rewrite; dropping the +bootstrap; timeout fallback; timeout re-arm; N/N−1 machinery; +client-computed hashes; fingerprint identities; plain cohort cookie; +`billing_outcome`), plus: **13.** injected in-realm ADM reporter — +rejected (hands bidder code the acceptance credential); owner-observed +`load`/`error` instead. **14.** cookie-routed CSP affinity — rejected +(opaque-origin reports carry no cookie); path-identity routing. **15.** +token-identity alone for arm attribution — rejected (authorization is +not a row dimension); arm-specific datasources + stamped union view. +**16.** indefinite diagnostic renewal — rejected; `dexp` ceiling. +**17.** shared attempt identity for parent/child — rejected; distinct +`attempt_id`. ## 11. Risks -- Hard-cutover blast radius — accepted by policy; bounded by the §0 - runbook (probes, low-weight canary, 24 h window, weight-back - rollback). -- Sticky-cohort routing is new infrastructure the cutover depends on — - Phase 0 work; its coherence test is release-gating. -- Mediator wire-contract change (`candidate_id` echo) — DR-4 gates - `merge_highest_cpm`; config validation enforces the block. -- Notification triggers become a published contract for PBS-path - demand — changing them later is a breaking change for SSP reporting. -- Required `[auction].currency` and `winner_selection` (mediated - deployments) are a deliberate startup-error class under §0. -- Beacon abuse — pre-parse caps, per-family origin policies, fail-closed - numeric limits, signed modes, credentialed diagnostics. -- Registry/limiter memory — explicit capacities, TTL reclamation, - reject-at-capacity; Fastly overshoot documented, not claimed. -- CSP data is advisory — never a sole rollback signal. -- Sink blindness — per-datasource probes with datasource-side queries. -- ABI freeze — `tsjs._internal.registry` is load-bearing; exact-release - verdicts are the contract. -- Schema generation — checked-in artifacts + staleness CI. +Revision 8's register stands (cutover blast radius; sticky-cohort +infrastructure; mediator wire-contract change; published notification +triggers; required-config startup errors; beacon abuse; memory bounds; +advisory CSP; sink blindness; ABI freeze; schema generation), plus: the +**observation-only control build** is new scoped work whose "zero +behavior change" property is itself gated (hermetic parity vs baseline); +and `assignment_id` persistence is pseudonymous but new — bounded by the +24 h token TTL and excluded from any identity join by schema review. ## 12. Success criteria -1. APS creatives render in each configured flow (SSAT, client-Prebid, - page-bids, direct), hermetically and in the release-gating real-GAM - suite per Appendix A.3's enumerated topologies. +1. APS creatives render in each configured flow, hermetically and in the + attested real-GAM suite. 2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the - failing class from one page load (including A1–A4 via the debug - envelopes, and including an initially-unsampled page via pre-upgrade - buffering); §5.7 SLIs hold on sink-backed deployments. -3. Both lint families pass with zero exceptions; stateful sharing only - via the registry; exact-release mismatches quarantine loudly. + failing class from one page load; §5.7 SLIs hold, including the alert + drill. +3. Both lint families (incl. the custom scope-aware rule) pass; stateful + sharing only via the registry; exact-release mismatches quarantine + loudly. 4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or generated. -5. Attempt counts key on `(trace_id, nav_gen, refresh_gen, slot)`; - traces stay navigation-scoped; no double counting; orphan recovery - has a non-vacuous test; G4a holds including caller-independent - retirement and same-class supersession. -6. The only TSJS-owned global is `window.tsjs` with the §7.4 final - shape; no expandos; legacy names gone at cutover. -7. §7.10 budgets hold on the dedicated pinned workflow. -8. No existing warning lost; issue-surfacing conditions log `warn`+ - with the beacon's reason code. -9. TypeScript floor matches resolved 5.9 with the §7.11 flags via the - checked-in `typecheck` script; `prebid.js` pin documented with the - deployed bundle. -10. `nurl`/`burl` fire only on carrying paths at their G4d binds, - attempt-scoped and idempotent; APS fires neither; hermetic - exactly-once tests pass; production duplicates alarm via - `notification_sent` and reconcile to zero via billing reports. -11. Trace-bearing responses are `private, no-store`; authorizations - are per-trace, signed, mode-carrying, renewal-preserving, with - diagnostic upgrade as the sole authenticated mode transition; - unsampled traces transmit nothing. -12. The cutover runbook rehearsed (weight switch, purge, rollback); - config-hash verification enforced. -13. The Phase-3 statistical and real-GAM gates pass on the exact - immutable Phase-5 release candidate before weight-up. -14. The Appendix A gates table shipped with this design; every change - carries a reviewed decision record. -15. The baseline APS fix behaviors are re-implemented in the target - architecture with the baseline browser tests passing unmodified. +5. Exactly one `render_terminal` per `attempt_id`; parent/child fallback + attempts are distinct rows; attempt aggregation keys on `attempt_id` + with the tuple as grouping only. +6. The only TSJS-owned global is `window.tsjs` (§7.4); no expandos. +7. §7.10 budgets hold, including the CDP heap procedure. +8. No existing warning lost; issue-surfacing conditions log `warn`+ with + the beacon reason. +9. TypeScript floor and flags via the checked-in `typecheck` script; + `prebid.js` pin documented. +10. `nurl`/`burl` only on carrying paths at their binds with the + normative dispatch mechanics; APS fires neither; hermetic + exactly-once + production alarm + reconciliation. +11. Trace-bearing responses `private, no-store`; authorizations signed, + mode-carrying, renewal-preserving, diagnostic bounded by `dexp`; + unsampled transmits nothing. +12. Cutover rehearsed; config-hash verification enforced; post-cutover + routing defaults flip (no stale-cookie stragglers). +13. Phase-3 statistical and real-GAM gates pass on the attested + immutable RC before weight-up. +14. Appendix A shipped with this design; changes carry decision records. +15. Baseline APS fix behaviors re-implemented; baseline browser tests + pass unmodified. ## 13. Open questions -Only one remains outside the decision records: does Amazon expose any -creative-completion acknowledgement that could add a -post-`render_accepted` state under a new name (future enhancement)? +One: does Amazon expose any creative-completion acknowledgement that +could add a post-`accepted` state under a new name (future +enhancement)? --- ## Appendix A — Normative rollout gates (initial values) -Owners are roles: **RO** = release owner, **QA** = QA owner, **OPS** = -release owner's on-call. Assignment unit = the authenticated sticky -cohort token (§0), randomized at HTML request, stratified by publisher -and slot. Statistical gates use **sampled traces only** (§8 method); -diagnostic traffic is reported separately. All production queries run -against canonical views via **checked-in versioned pipes** -(`tinybird/pipes/gate_.pipe`); probe/parity suites are checked-in -scripts (`scripts/gates/.sh`) or workflows. "Hold" = router -weight frozen; "Rollback" = weight to previous release + re-purge. -Changing any row requires a reviewed decision record. +Roles: **RO** release owner, **QA** QA owner, **OPS** on-call. +Randomization unit: `assignment_id` (§0). Statistical gates: sampled +traces, cluster bootstrap (§8), canonical views only, checked-in +artifacts (`tinybird/pipes/gate_*.pipe`, `scripts/gates/*.sh`, +`.github/workflows/*`). Every statistical row specifies +numerator/denominator/source; missing rows count as failure. "Hold" = +weight frozen; "Rollback" = weight back + re-purge. Changes require +decision records. Low volume: inconclusive → extend once → Hold. ### A.1 Phase gates -| Phase | Gate | Artifact (checked in) | Denominator | Floor | Threshold | Window | Owner | Action | -| ----- | ----------------------------- | ---------------------------------------------------------- | --------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------- | ------ | ----- | -------- | -| 0 | Dark-pool health | `scripts/gates/probe-pool.sh` | probe requests | 1,000 | 100% expected responses | 24 h | OPS | Hold | -| 0 | Schema validation | `scripts/gates/schema-writes.sh` (deterministic writes) | synthetic rows | 10,000 | **0 rejections** (writes are deterministic) | 24 h | RO | Hold | -| 0 | Asset identity | `scripts/gates/asset-probe.sh` | probed hashes | all | 0 misses / 0 wrong-status | once | QA | Hold | -| 0 | Config binding | `scripts/gates/config-hash.sh` | pools | all | manifest hash verified on every pool | once | OPS | Hold | -| 1 | ABI cleanliness | `gate_abi.pipe` (probe pages) | probe page loads | 1,000 | 0 `abi_mismatch`/`bundle_partial` | 24 h | QA | Hold | -| 1 | Bootstrap ownership | hermetic suite `bootstrap-ownership.spec` | checkpoints incl. hung-resume | all | 100% unwind/self-discard | CI | QA | Hold | -| 2 | Ingest HTTP parity | `scripts/gates/ingest-parity.sh` (4 adapters, 4 families) | parity cases | all | 100% | CI | QA | Hold | -| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | probe batches | 10,000 events | acceptance ≥ 99%; dedup exactly-once | 24 h | OPS | Hold | -| 2 | Per-sink probes | `gate_probes.pipe` (3 datasources × adapters) | probe writes per sink | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | -| 3 | Funnel: ssat/prebid/page_bids | `gate_funnel.pipe` per flow | eligible APS wins (sampled traces), per flow-arm | 10,000 each | per A.2 | 24 h | RO | Rollback | -| 3 | Funnel: direct/fallback | hermetic + real-GAM rows (A.3) — not statistical | suite cases | all | 100% | CI+RG | QA | Hold | -| 3 | Attribution soundness | `gate_cycles.pipe` | **all TS request cycles candidate for attribution** | 10,000 | `cycle_unattributable` < 0.5% | 24 h | RO | Rollback | -| 3 | GAM fill | `gate_fill.pipe` | cohort ad requests per arm | 10,000 | one-sided 95% CB: rel. diff ≥ −2% | 24 h | RO | Rollback | -| 3 | Latency | `gate_latency.pipe` | cohort attempts per arm | 10,000 | one-sided 95% CB: p95 rel. diff ≤ +2% | 24 h | RO | Rollback | -| 3 | Billing | `gate_billing.pipe` + GAM report reconciliation | attempts per arm (per-1,000 normalization) | 100,000 or 7 d | one-sided 95% CB: rel. diff ≥ −2% | window | RO | Rollback | -| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` (detection) + billing reconciliation | burl dispatches | 1,000 | 0 observed duplicates; reconciliation clean | 24 h | RO | Rollback | -| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | -| 4 | Four-flow parity | `flow-parity.spec` (hermetic) | parity cases | all | 100% | CI | QA | Hold | -| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / within §7.10 tolerances | CI | QA | Hold | -| 5 | RC re-canary | repeat all Phase-3 rows on the immutable RC | as Phase 3 | as Phase 3 | as Phase 3 | 24 h | RO | Rollback | -| 5 | Cutover monitor | `gate_slis.pipe` | production traffic | — | probe lag ≤ 5 min; probe loss < 0.1%; `render_fail` rate ≤ pre-cutover canary + 0.5 pt | 24 h | OPS | Rollback | - -Low-volume handling: a statistical gate that cannot reach its floor in -its window is inconclusive — extend once; a second inconclusive is a -Hold, never a pass. - -### A.2 Expected stages per flow (Phase 3 funnel) - -Denominators are named per stage; document/runner rates apply wherever -the renderer document participates. - -| Flow | Expected sequence | Stage thresholds (each vs its named denominator) | -| --------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_accepted` | each ≥ 95% of prior; `renderer_document_loaded`/`bridge_response_sent` ≥ 99%; `runner_failed`+timeouts ≤ 1% of `renderer_document_loaded` | -| prebid | same as ssat (keyed by Prebid `adId`) | same | -| page_bids | same as ssat (after SPA navigation) | same | -| direct | `render_attempt → renderer_document_loaded → render_accepted` | document ≥ 99% of attempts; accepted ≥ 95% of attempts (hermetic/real-GAM gate, not statistical) | -| fallback | parent `gam_empty` → child `fallback_start → renderer_document_loaded → render_accepted` | accepted ≥ 95% of `fallback_start` (hermetic/real-GAM gate, not statistical) | - -### A.3 Real-GAM suite (operational row) - -| Field | Value | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------- | -| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | -| Topologies | one per A.2 flow, plus publisher-overlap and disabled-initial-load formation (G4a), plus the same-class supersession case | -| Browsers | Chromium, Firefox, WebKit (CSP/opaque-origin rows); Chromium (funnel rows) | -| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | -| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | -| Command | `npx playwright test --config real-gam.config.ts` from the browser test package | -| Artifact | Playwright HTML report + trace zips as workflow artifacts, retained 90 days | -| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | -| Approval evidence | green workflow run URL linked in the release checklist, signed off by RO | +| Phase | Gate | Artifact | Numerator / denominator (source) | Floor | Threshold | Window | Owner | Action | +| ----- | ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- | --------------- | -------------------------------------------------- | ------------------------- | ----- | -------- | +| 0 | Dark-pool health | `probe-pool.sh` | expected responses / probe requests | 1,000 | 100% | 24 h | OPS | Hold | +| 0 | Schema validation | `schema-writes.sh` | accepted / deterministic synthetic rows (all tables) | 10,000 | **0 rejections** | 24 h | RO | Hold | +| 0 | Asset identity | `asset-probe.sh` | correct status / probed hashes | all | 100% | once | QA | Hold | +| 0 | Config binding | `config-hash.sh` | verified pools / pools | all | 100% | once | OPS | Hold | +| 1 | Kernel/bootstrap (hermetic) | `bootstrap-ownership.spec` + counters | passing cases / cases (no beacon dependency) | all | 100% | CI | QA | Hold | +| 2 | Ingest HTTP parity | `ingest-parity.sh` | passing / parity cases (4 adapters × 4 families) | all | 100% | CI | QA | Hold | +| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | accepted probe events / sent (probe tokens) | 10,000 | ≥ 99%; dedup exactly-once | 24 h | OPS | Hold | +| 2 | Per-sink authenticated probes | `gate_probes.pipe` | on-time probe rows / expected (`probe_run_id×seq`), per datasource × sink-backed adapter | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | +| 2 | Alert drill | `alert-drill.sh` | alerts ≤ 1 h / injected failure episodes | 3 episodes | 100% | 24 h | OPS | Hold | +| 3 | Funnel ssat/prebid/page_bids | `gate_funnel.pipe` | per A.2 stage pairs (`ts_render_attempts_v` ⋈ slot-level auction rows) | 10,000/flow-arm | per A.2 | 24 h | RO | Rollback | +| 3 | Direct/fallback conformance | hermetic + A.3 rows | passing / suite cases | all | 100% | CI + RG | QA | Hold | +| 3 | Attribution soundness | `gate_cycles.pipe` | `cycle_unattributable` / **all attribution-candidate TS cycles** | 10,000 | < 0.5% | 24 h | RO | Rollback | +| 3 | GAM fill | `gate_fill.pipe` | nonempty `slotRenderEnded` / TS request cycles, canary vs control | 10,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 24 h | RO | Rollback | +| 3 | Latency | `gate_latency.pipe` | p95 of (`render_terminal{accepted}.t_rel_ms − attempt_started.t_rel_ms`) per arm | 10,000/arm | 1-sided 95% CB rel. diff ≤ +2% | 24 h | RO | Rollback | +| 3 | Billing | `gate_billing.pipe` + GAM `ts_arm` | revenue per 1,000 attempts per arm (GAM report ⋈ attempts) | 100,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 7 d (+7 d ext.) | RO | Rollback | +| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` + reconciliation | duplicate `notification_sent{burl}` per `notif_id` / dispatches; GAM-vs-server deltas | 1,000 | 0 observed; reconciliation within 1% | 24 h / billing wnd | RO | Rollback | +| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | +| 4 | Four-flow parity | `flow-parity.spec` | passing / parity cases | all | 100% | CI | QA | Hold | +| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / §7.10 tolerances | CI | QA | Hold | +| 5 | RC re-canary | all Phase-3 rows on the attested RC | as Phase 3 | as Phase 3 | as Phase 3 | **each row's own window** | RO | Rollback | +| 5 | Cutover monitor | `gate_slis.pipe` | probe freshness/loss; `render_terminal{failed}` rate vs pre-cutover canary | — | lag ≤ 5 min; loss < 0.1%; failed ≤ canary + 0.5 pt | 24 h | OPS | Rollback | + +### A.2 Expected stages per flow + +| Flow | Expected sequence | Stage thresholds (named denominators) | +| --------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_terminal{accepted}` | `targeting_set`/eligible wins ≥ 98%; each later stage ≥ 95% of prior; document/`bridge_response_sent` ≥ 99%; runner fail+timeout ≤ 1% of document | +| prebid | same (keyed by Prebid `adId`) | same | +| page_bids | same (post-SPA-navigation) | same | +| direct | `attempt_started → renderer_document_loaded → render_terminal{accepted}` | document ≥ 99% of attempts; accepted ≥ 95%; runner fail+timeout ≤ 1% of document (hermetic + real-GAM) | +| fallback | parent `render_terminal{failed, gam_empty}` → child `fallback_start → renderer_document_loaded → render_terminal{accepted}` | `fallback_start`/eligible parent `gam_empty` ≥ 99%; accepted ≥ 95% of starts; runner fail+timeout ≤ 1% of document (hermetic + real-GAM) | + +### A.3 Real-GAM suite (attested) + +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | +| **Input** | **the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}`** | +| **Output** | **attested report embedding the manifest** — a green run attests the exact build it exercised, parameterized by (release, pool, deployment epoch) | +| Topologies | one per A.2 flow; publisher-overlap; disabled-initial-load formation; same-class supersession | +| Browsers | Chromium, Firefox, WebKit (CSP/opaque rows); Chromium (funnel rows) | +| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | +| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | +| Command | `npx playwright test --config real-gam.config.ts` | +| Artifact | Playwright HTML report + trace zips, retained 90 days | +| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | +| Approval evidence | green attested run URL in the release checklist, signed off by RO | From fba6e7e664ff41a2b7c038ea6fe8533b9513e302 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:38:39 -0700 Subject: [PATCH 011/194] Revise design spec for the ninth review round Revision 10 closes the release-blocking findings and restores true self-containment. The router now injects a trusted internal cohort header the ingest stamps server-side (assignment_id never a client field); the observation-only control build gets a normative event-parity contract so both arms populate the funnel and latency gates identically; affinity token lifetime covers the full experiment window with same-arm renewal and a publisher-host binding in the MAC, and cutover/rollback explicitly retire the opposite release on every request family; the canonical attempt key becomes (publisher_domain, trace_id, attempt_id) with a per-trace collision-retry, and the slot funnel joins a dedicated one-row-per-(auction_id, canonical_slot) summary view with shared client and server slot normalization; the bridge acknowledgement splits into a claim phase that mints the nonce after validating the reserved adId and kernel-held generations and an ack phase that validates the nonce, since the baseline PUC request cannot carry it; a materialized request_cycle_started event gives fill and attribution a real denominator; AuctionBatch terminates every still-live child no_bid after processing a partial multi-slot response; attempt_started.source and render_terminal.source become source-presence-driven; probe run metadata is bound to the issued authorization and added to the CSP and ops schemas; the drop enum adds unsupported_currency and missing_request_context under one shared typed enum; an admin issuance route family joins the internal-route contract; the Image() notification fallback is removed; billing gets an impression-level or aggregate estimator that the cluster bootstrap can actually run; the real-GAM workflow independently verifies the deployed build against its manifest and emits OIDC-backed provenance; a server-side sampled exposure row makes whole-missing traces detectable; the heap budget is renamed to retained-heap; the typecheck gate uses the package script; ts_arm is reserved and A/A-gated; and the alternatives and risk registers are inlined in full. --- ...s-render-fix-and-tsjs-resilience-design.md | 529 ++++++++++++------ 1 file changed, 353 insertions(+), 176 deletions(-) diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index b988e6286..78a31b9b7 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1,8 +1,11 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 9 — closes the eighth review round's mandatory set: - attempt schema, diagnostic renewal, telemetry/gate model, CSP routing, and - rollout measurement. Fully self-contained. +- **Status:** revision 10 — closes the ninth review round (assignment-id + trusted path, control-build event parity, affinity lifetime, two-phase + bridge ack, missing-slot termination, materialized cycle denominator, + publisher-bound affinity token, admin route family, billing estimator + source, independent RC attestation) and re-inlines the alternatives and + risk registers so the document is genuinely self-contained. - **Date:** 2026-08-04 - **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed GAM shells"). All file:line citations refer to this commit. @@ -31,23 +34,45 @@ - Assets: embedded only; hashed pathnames for cache identity; unknown hash → `410`, `no-store`. - **Authenticated sticky affinity.** The router sets `ts-rel` on HTML - responses — format `r1...... -`: `kid ^[a-z0-9-]{1,16}$`; canonical decimal `exp` (TTL 24 h); - `release` from the allowlist; `cohort ∈ {canary, control}`; - `assignment_id` = 32-hex CSPRNG (the pseudonymous **randomization unit**, - §8); `sig` = unpadded base64url HMAC-SHA-256 over the domain-separated - length-prefixed input `"ts-affinity-v1" || u32be-len fields || -u64be(exp)`; keys owned and rotated by the routing layer (active + - previous, ≥ 24 h retention); constant-time verification; test vectors - checked in. Attributes `Secure; HttpOnly; SameSite=Lax; Path=/`. Cache - keys use the post-validation release label only. + responses — format + `r1.......`: + `kid ^[a-z0-9-]{1,16}$`; canonical decimal `exp`; `phost` = normalized + publisher host; `release ^[a-z0-9._-]{1,40}$` from the allowlist; + `cohort ∈ {canary, control}`; `assignment_id` = 32-hex CSPRNG (the + pseudonymous **randomization unit**, §8); `sig` = unpadded base64url + HMAC-SHA-256 over the domain-separated length-prefixed input + `"ts-affinity-v1" || u32be(len)||field …` over **every** field + including `phost` (so a valid token replayed against another publisher + host fails); keys owned/rotated by the routing layer (active + previous, + retained ≥ the affinity lifetime + skew); constant-time verification; + test vectors checked in. Attributes `Secure; HttpOnly; SameSite=Lax; +Path=/`. Cache keys use the post-validation release label only. +- **Affinity lifetime covers the experiment.** `exp` TTL is set to the + **maximum experiment window** in force (default 14 days — the billing + gate's 7 d + 7 d extension), not 24 h, so a canary visitor never crosses + over to control mid-experiment. Renewal on any HTML response before + expiry re-signs the **same `assignment_id` and `cohort`** with a fresh + `exp` (a browser session's arm is fixed for the experiment). Expired + tokens are distinguished from forged ones (expired = valid sig, past + `exp`) and, during an active experiment, expired-but-valid tokens are + renewed to their original arm rather than reassigned. +- **assignment_id reaches telemetry only by a trusted server path.** The + cookie is `HttpOnly`; the client never reads it and never sends + `assignment_id`. The router **strips any inbound assignment/cohort + header**, validates `ts-rel`, and injects trusted internal metadata + (`X-TS-Cohort`, `X-TS-Assignment`, `X-TS-Release`) on the proxied + request; the client-events handler stamps those onto every row + server-side. Ingest rejects any client-supplied assignment/cohort field. + Tests: spoofed inbound header stripped; wrong-arm; null-rate; cookie + expiry mid-session; cross-pool. - **State-dependent routing defaults:** during canary, valid tokens route by - their binding; invalid/expired/forged → control + reissue. **After forward - cutover ("weight 100%"), the safe default flips:** stale, invalid, or - control-bound tokens on HTML requests are reassigned to the active - release; non-HTML requests with unknown or stale tokens route to the - active release — 100% means 100%, not "except 24 h of old cookies." - Rollback flips the default back. + their binding; invalid/forged → control + reissue; expired-but-valid → + renewed to their arm (above). **Forward cutover ("weight 100%") + explicitly retires the old release:** for HTML **and** non-HTML request + families, stale/invalid/old-release-bound tokens are reassigned/routed to + the active release — 100% means 100%. **Rollback symmetrically retires + the new release:** still-valid canary bindings are overridden on every + request family, not merely defaulted. - **CSP-report affinity never depends on cookies:** the renderer is sandboxed without `allow-same-origin` (`aps/render.ts:4`), so its browser-generated reports are cross-origin to the publisher endpoint and @@ -58,13 +83,21 @@ u64be(exp)`; keys owned and rotated by the routing layer (active + - Beacon and trace-auth transports use `credentials: "same-origin"` so the affinity cookie routes them; handlers derive no identity from cookies. - **Canary/control measurement (closing the empty-control-arm gap):** the - control pool for Phase-3 statistics runs an **observation-only control - build** — the baseline plus Phase-2 instrumentation only (trace, beacon, - probes; zero behavior changes) — so both arms emit comparable sampled - telemetry. Arms write to **arm-specific datasources**; the canonical - union view stamps a trusted `deployment_pool` dimension from the write - identity (token → dataset → arm), making the arm a queryable row - dimension rather than an authorization side effect. + control pool runs an **observation-only control build** — baseline render + _decisions_, but with **every measurement-only lifecycle hook the + Phase-3 gates read**. The control build implements a normative + **event-parity contract**: it emits `request_cycle_started`, + `attempt_started`, `bridge_request`, `bridge_response_sent`, + `renderer_document_loaded`/`adm_document_loaded`, and `render_terminal` + at the **same timing points** as the canary build, populated from + baseline behavior (e.g. its own `slotRenderEnded`, its own bridge + responses) — only the render-decision code differs, never the event + definitions or their emission points. Both arms therefore populate the + funnel and latency gates identically. Arms write to **arm-specific + datasources**; the union view stamps a trusted `deployment_pool` from the + write identity. The event-parity contract is itself gated by an **A/A + test** (canary-build vs canary-build) that must show zero metric + movement before any A/B canary begins. - Router weight over sticky cohorts is the sole activation primitive; flags are in-pool emergency kill switches. Cutover = weight 100% + CDN purge; rollback = weight back + re-purge. The affinity acceptance test covers @@ -191,28 +224,40 @@ class as the existing `ts-debug` comment). The **diagnostic credential** `ext.trusted_server.trace = {trace_id, auth, auction_id}`. - **Attempt identity (closing the parent/child collision):** every render attempt mints a client-side **`attempt_id`** (8-char `[a-z0-9]`, - CSPRNG, unique per trace) and carries nullable **`parent_attempt_id`** - (fallback children reference their parent). The tuple - `(trace_id, nav_gen, refresh_gen, slot)` remains a **grouping key - only**; `ts_render_attempts_v` keys by `attempt_id`. Exactly one - terminal event per `attempt_id` (G4c) is a tested invariant. + CSPRNG) and carries nullable **`parent_attempt_id`** (fallback children + reference their parent). **The canonical attempt key is + `(publisher_domain, trace_id, attempt_id)`** — `attempt_id` alone (≈ 2.8 + T values) is not globally unique at production volume, so + `ts_render_attempts_v` keys and parent lookups use the full triple. + Uniqueness within a trace is guaranteed by a **per-trace live-set + collision check with retry** (the `NavigationSession` holds the issued + set; a collision re-draws). The tuple + `(trace_id, nav_gen, refresh_gen, slot)` remains a **grouping key only**. + Exactly one terminal event per attempt key (G4c) is a tested invariant. - **Deterministic keyed sampling:** first 8 bytes of `HMAC-SHA-256(sampling_key, trace_id)` as u64 BE; `sampled` iff `u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate` finite in `[0, 1]`. - **Cross-tier join:** equality key = the server telemetry **`auction_id`** (UUID; the client column is `Nullable(UUID)` and ingest validates canonical UUID syntax). Generations are client-side - only. **Join grains are explicit:** auction-level joins hit the one - summary row per `auction_id`; slot-level joins use - `auction_id + slot + row_kind`; bid-level joins are opt-in for - bid-grained analyses. Raw attempt × auction-row joins are forbidden - (row multiplication). + only. **Join grains are explicit, and the slot grain goes through a + dedicated summary view.** `auction_events_raw` has multiple + `row_kind=slot` rows per slot (bid, provider, drop, selection), so a + `auction_id + slot` join multiplies rows; funnels therefore join against + **`ts_auction_slot_summary_v` — one row per `(auction_id, +canonical_slot)`** built from the `selection_summary` slot rows. + Auction-level joins hit the one totals/summary row per `auction_id`; + bid/drop/provider joins carry their real grain and are opt-in. **Client + `slot` and server `slot_id` use the same canonical normalization** + (lowercased, `-container` suffix stripped — the resolution rule of + `gpt/index.ts:112-138`), applied on both sides and tested. - Cache-privacy invariant: traces/authorizations only in per-request auction-bearing responses; such HTML is `private, no-store`. - Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry `{nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?, -auction_id?, t_rel_ms?}`. **`t_rel_ms`** is a bounded monotonic - duration (`performance.now()` truncated to u32 ms, relative to +auction_id?, t_rel_ms?}`. **`t_rel_ms`** (time, relative, in + milliseconds) is a bounded monotonic duration (`performance.now()` + truncated to u32 ms, relative to navigation start) — the latency gates' basis; `received_at` is ingest time and is never used as event time. `flow` is closed: `ssat | prebid | page_bids | direct | fallback | system`. @@ -264,25 +309,44 @@ class or opposite — supersedes a pending uncertain intent**, and if the uncertain one could still be in flight, the next `slotRequested` is ambiguous → quarantine. Cycles open only on `slotRequested` (causal head; SRA = one per slot per batch) and close on `slotRenderEnded` -(`responseIdentifier` dedups drain). One outstanding TS cycle per slot; -one queued replacement. Attribution requires exactly one outstanding TS -cycle and no overlap; otherwise `cycle_unattributable`, fail closed. **No -timeout re-arm** — re-arm only on count-based drain, safe TS-owned -destroy/redefine, or page end; unissued intents are NavigationSession -children; physical state is RuntimeSession. Deterministic-harness CI + +(`responseIdentifier` dedups drain). **Each attributable `slotRequested` +emits exactly one `request_cycle_started` event** — the materialized +denominator the fill and attribution gates divide by (`attempt_started` +is not equivalent: an attempt can fail before producing a physical +request). One outstanding TS cycle per slot; one queued replacement. +Attribution requires exactly one outstanding TS cycle and no overlap; +otherwise `cycle_unattributable`, fail closed. **No timeout re-arm** — +re-arm only on count-based drain, safe TS-owned destroy/redefine, or page +end; unissued intents are NavigationSession children; physical state is +RuntimeSession. Deterministic-harness CI + the release-gating real-GAM suite (Appendix A.3). -**G4b — Acknowledgement, per render path.** Nonces are per-attempt -128-bit CSPRNG values; the kernel validates source ownership (§6.8), -nonce, token, `nav_gen`, `refresh_gen` before transitions or -notifications; navigation/supersession invalidates; late acks → -`stale_navigation`. Deadlines: document 3 s, runner 10 s, adm 5 s. - -1. **APS-PUC** (baseline transport): MessageChannel into the renderer - document (`ports.length` checks, exact-key replies, one-shot latch, - port close); the document posts authenticated - `renderer_document_loaded` then the accepted/failed result to the top - window. +**G4b — Two-phase acknowledgement (the nonce is minted by the claim, not +carried in the request).** The baseline PUC request is `{message, adId}` + +- a `MessagePort` — it cannot carry a nonce or generations, and the nonce + can only exist **after** a claim succeeds. So the protocol is two phases: + +* **Phase 1 — claim (on the incoming `Prebid Request`):** validate source + ownership (§6.8), the reserved `adId` against the reservation store, and + the internal current `nav_gen`/`refresh_gen` **held in the kernel** + (not in the message); on success, **mint the per-attempt 128-bit CSPRNG + nonce**, bind notifications, reply over the `MessagePort` (the renderer + depends on this reply to settle its PUC promise), and — for the APS + path — hand the nonce to the renderer document in the response. +* **Phase 2 — acknowledgement (later document/runner messages):** validate + source, nonce, token, `nav_gen`, `refresh_gen`. Navigation/supersession + invalidates the nonce; late acks → `stale_navigation`. Deadlines: + document 3 s, runner 10 s, adm 5 s. + +Per render path: + +1. **APS-PUC** (baseline transport): the Phase-1 reply travels over the + `MessagePort`; the response also transfers the freshly minted nonce + into the renderer document (`ports.length` checks, exact-key replies, + one-shot latch, port close); the document then posts authenticated + `renderer_document_loaded` and the accepted/failed result to the top + window (Phase 2). 2. **Generic ADM/cache-PUC:** **the acceptance observation lives in the trusted owner, not the creative document** — the owner observes its own iframe's `load`/`error` events and emits `adm_document_loaded`; @@ -315,11 +379,15 @@ fallback: attributed parent `gam_empty` immediately before child render). `nurl` at bind; `burl` at `accepted`; idempotency key `(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`. **Dispatch mechanics (normative):** macros are expanded server-side -only; the client fires +only; the client fires exactly one `fetch(url, {method: "GET", mode: "no-cors", credentials: "omit", -redirect: "follow", referrerPolicy: "no-referrer", keepalive: true})`; -on synchronous failure the fallback is a detached `Image()` request; no -retries either way. Every dispatch emits +redirect: "follow", referrerPolicy: "no-referrer", keepalive: true})` +and awaits its settlement. **There is no `Image()` fallback** — a +detached image request would construct a separate credentialed, +referrer-bearing request outside this privacy contract; a request that +rejects (construction error) or resolves to a network failure emits +`notification_sent{result: failed}` and stops. No retries. Every +dispatch emits `notification_sent{kind, notif_id, result: queued | failed}` with the **server-minted `notif_id`** (12-char token delivered with the bid). Duplicates: hermetic exactly-once proof + production detection alarm + @@ -341,7 +409,11 @@ child identity before any effect. Tests: partial overlap, full overlap, timeout, navigation disposal, reversed responses. The auction client returns a discriminated result — `{ok: bids[]} | {error: "auction_timeout" | "network_error" | "http_error" | "invalid_response"}` -(§3.14); only a parsed empty response is `no_bid`. Public API: +(§3.14); only a parsed empty response is `no_bid`. **After the batch +processes every response bid, each still-live child with no valid +matching winner is terminated `render_terminal{no_bid}`** — a response +carrying only slot X never leaves slot Y's child nonterminal, so +`RequestAdsResult` always settles. Public API: `tsjs.requestAds(options): Promise`, `RequestAdsResult = {traceId, slots: [{slot, outcome, reason?}]}`, settling when every child attempt is terminal. @@ -365,15 +437,21 @@ implying a live channel that does not exist. publication (config is a release-time input); serving is lookup-only; unknown vector = build error; unknown hash = `410 no-store`; exact match = `public, max-age=31536000, immutable`. -- **Internal route families — four** (renderer, client-events, - CSP-report, `/_ts/trace-auth`): dispatch before auth/EC/publisher/ - integration filters (`app.rs:709` orders these wrong today); all - methods + version prefixes reserved locally (405 + `Allow` + - `no-store`; unknown version 404 `no-store`; no publisher fall-through, - `adapter-spin app.rs:804`); no body/cookie/authorization forwarding. - **Per-family origin policy:** client-events + trace-auth strict - normalized same-origin; CSP admits opaque/`null` origins with path - identity + limits; renderer is a public GET validated by version/path. +- **Internal route families — five** (renderer, client-events, + CSP-report, `/_ts/trace-auth`, and the **admin issuance family** + `/_ts/admin/*` — diagnostic-credential and probe-authorization, §5.3): + all dispatch before auth/EC/publisher/integration filters (`app.rs:709` + orders these wrong today); all methods + version prefixes reserved + locally (405 + `Allow` + `no-store`; unknown version 404 `no-store`; no + publisher fall-through, `adapter-spin app.rs:804`); no + body/cookie/authorization forwarding to the publisher. **Per-family + origin/auth policy:** client-events + trace-auth strict normalized + same-origin; CSP admits opaque/`null` origins with path identity + + limits; renderer is a public GET validated by version/path; **the admin + family sits behind operator authentication with its own rate limits and + exists on all adapters that expose an admin plane** (elsewhere it is + absent, and diagnostic/probe modes are unavailable there — stated, not + implied). All five families appear in the four-adapter parity matrix. - Ingest routes in all four adapters; Fastly has real sinks; others accept-count-drop (DR-5). §5.6 schemas deploy before writers. @@ -389,9 +467,10 @@ implying a live channel that does not exist. | `t` | fields | allowed `flow` | | -------------------------- | --------------------------------------------- | ----------------------- | +| `request_cycle_started` | slot | ssat, prebid, page_bids | | `bid_received` | slot, id_kind, source | render flows | | `targeting_set` | slot, id_kind | render flows | -| `attempt_started` | slot, source | render flows | +| `attempt_started` | slot, source? | render flows | | `bridge_request` | slot, id_kind, matched | ssat, prebid, page_bids | | `bridge_response_sent` | slot, source | ssat, prebid, page_bids | | `render_terminal` | slot, outcome, reason?, source? | render flows | @@ -408,13 +487,18 @@ implying a live channel that does not exist. | `heartbeat` | probe_run_id, expected_seq, adapter, target | system | Render flows = `ssat | prebid | page_bids | direct | fallback`. -`attempt_started` carries the attempt's `t_rel_ms` baseline; the latency -metric is `render_terminal{accepted}.t_rel_ms − -attempt_started.t_rel_ms` per attempt. `source` on `render_terminal` is -nullable for pre-source reasons (`gpt_absent`, `pbjs_absent`, -`slot_unresolved`, `intent_no_request`, `abi_mismatch`, `registry_full`, -`bundle_partial`); the per-event/per-reason validity matrix is a -generated artifact (§6.7). Reason enum: `renderer_document_no_load`, +`attempt_started` carries the attempt's `t_rel_ms` baseline and its +`source` is **optional** (direct attempts start before a response selects +a source); the latency metric is `render_terminal{accepted}.t_rel_ms − +attempt_started.t_rel_ms` per attempt. **`render_terminal.source` is +present iff a render source was actually bound** — absent for +`no_bid`/`cancelled` and every pre-winner outcome (`auction_timeout`, +`network_error`, `http_error`, `invalid_response`) as well as the +pre-source reasons (`gpt_absent`, `pbjs_absent`, `slot_unresolved`, +`intent_no_request`, `abi_mismatch`, `registry_full`, `bundle_partial`); +the generated per-event/per-reason validity matrix (§6.7) encodes +source-presence by whether a source was bound, not by a hand-list. +Reason enum: `renderer_document_no_load`, `runner_no_load`, `runner_failed`, `descriptor_invalid`, `invalid_dimensions`, `dimensions_out_of_range`, `bridge_id_mismatch`, `cycle_unattributable`, `intent_no_request`, `stale_navigation`, @@ -525,13 +609,18 @@ same-origin` else normalized `Origin` equality; absent both → `attempt_id`**); the arm-union views stamp `deployment_pool` from the write identity (§0). Dashboards/alerts query canonical views only. - The Fastly sink is fire-and-forget (`tinybird.rs:153`) — - **per-datasource authenticated probes**: every probe row carries - `{probe_run_id, expected_seq, adapter, target}`; client-events via - `heartbeat` events under probe-mode tokens; CSP via probe reports to a - **secret-derived probe `policy_id`** (registered like any policy id, - not guessable — `policy_id = "probe"` would be publicly forgeable); - ops via an authenticated probe counter write. **Persistence gates are - scoped to sink-backed adapters** (DR-5); accept-count-drop adapters + **per-datasource authenticated probes**. **Every probe-capable table + carries `{probe_run_id, expected_seq, adapter, target}`** (added to + `ts_csp_reports` and `ts_ops_counters` below, not only + `ts_client_events`), so loss queries distinguish runs, retries, resets, + and adapters. **These four fields are cryptographically bound to the + issued probe authorization** — the admin-issued probe token + (`POST /_ts/admin/probe-authorization`, §5.3) signs `probe_run_id`, + `adapter`, and `target`, and the ingest handler stamps the row from the + verified token rather than trusting submitted values. A secret-derived + CSP probe `policy_id` is a bearer capability for _routing_ only; it is + never treated as proof of authentic run metadata. **Persistence gates + are scoped to sink-backed adapters** (DR-5); accept-count-drop adapters get HTTP-parity gates only. Freshness = probe lag ≤ 5 min; loss = `expected_seq` gaps < 0.1%; alert owner: release owner's on-call. - **Alert-delivery drill:** a synthetic canary page injects a known @@ -582,27 +671,37 @@ duplicate_bid_id, bid_id_too_large, empty_seatbid, empty_seatbid_bids, unknown_impid, invalid_price, unsupported_media_type, creative_id_too_large, renderer_extension_serialization_failed, no_render_source, -lost_to_higher_bid, overflow` — covering `aps.rs:740-929` - (incl. `empty_seatbid_bids` at `:875`) and `formats.rs:408-419`; a - **compile-time exhaustiveness test maps every producer to the enum**. +lost_to_higher_bid, unsupported_currency, missing_request_context, +overflow` — covering every baseline producer at `aps.rs:740-929` + (incl. `empty_seatbid_bids` at `:875`, `unsupported_currency`, and + the response-level `missing_request_context`, whose totals/error-row + disposition is `row_kind = totals` with a null slot) and + `formats.rs:408-419`. `currency_mismatch` and `unsupported_currency` + are **distinct** (config-vs-response mismatch vs an unsupported + currency code). Producers emit a **shared typed enum, no string + literals**, so the **compile-time exhaustiveness test is real**: every + variant maps to a producer and every producer to a variant. - **`ts_csp_reports`**: `received_at DateTime64, publisher_domain LowCardinality(String), release_id String, policy_id LowCardinality(String), cohort LowCardinality(String), directive_bucket Enum(script|style|frame|img|connect|font|media|worker|other), source_bucket Enum(https_host_allowlisted|data|blob|inline|eval|other), -count UInt32`; sorting key `(publisher_domain, received_at, policy_id)`; - TTL 30 days. Ingest: body ≤ 8 KiB, ≤ 10 reports/request, strings ≤ 256, - nesting ≤ 4, both media types with separate validators, unused fields - discarded, own limiter bucket. **Caps with values:** 10,000 - reports/hour/publisher and 1,000/hour/cohort; overflow increments the - `csp_overflow` ops counter (dropped reports counted, never parsed - further). +count UInt32, probe_run_id Nullable(String), expected_seq +Nullable(UInt32), adapter Nullable(Enum), target Nullable(Enum)`; + sorting key `(publisher_domain, received_at, policy_id)`; TTL 30 days. + Ingest: body ≤ 8 KiB, ≤ 10 reports/request, strings ≤ 256, nesting ≤ 4, + both media types with separate validators, unused fields discarded, own + limiter bucket. **Caps with values:** 10,000 reports/hour/publisher and + 1,000/hour/cohort; overflow increments the `csp_overflow` ops counter + (dropped reports counted, never parsed further). - **`ts_ops_counters`**: `received_at DateTime64, publisher_domain LowCardinality(String), release_id String, counter Enum(renderer_requests|renderer_unknown_version|renderer_auth_blocked| ingest_accepted|ingest_dropped|ingest_rate_limited|abuse_flagged| -csp_overflow|probe), value UInt64`; sorting key `(publisher_domain, -received_at, counter)`; TTL 90 days. +csp_overflow|probe), value UInt64, probe_run_id Nullable(String), +expected_seq Nullable(UInt32), adapter Nullable(Enum), target +Nullable(Enum)`; sorting key `(publisher_domain, received_at, counter)`; + TTL 90 days. - **Sink plumbing:** one generic multi-target Tinybird sink trait; `RuntimeServices` (`platform/types.rs:158`) gains handles for client-events, CSP, and ops targets beside the auction sink; each @@ -640,14 +739,17 @@ explicit `winner_selection`. ### 6.1 Mediation -Eight rules, arrival-independent, one shared helper across both +Seven rules, arrival-independent, one shared helper across both lifecycles: (1) required `[auction].currency` (APS + non-USD = startup error; Prebid validates at parse, `prebid.rs:2433`); (2) candidate identity `source_candidate_id = (provider_name, upstream_bid_id)` with the upstream id **required, ≤ 64 chars, unique** — missing/duplicate/ oversized → `bid_drop{missing_bid_id | duplicate_bid_id | -bid_id_too_large}`, **no fingerprint fallback**; `candidate_id` (CSPRNG -wire echo, never an ordering key); (3) mediator echoes +bid_id_too_large}`, **no fingerprint fallback**; `candidate_id` is a +server-minted 12-char `^[a-z0-9]{12}$` CSPRNG wire echo with in-auction +collision retry, **never an ordering key**, and the mediator's echoed +value is validated against the issued set on return (an echo matching no +issued id is `provenance_invalid`); (3) mediator echoes `ext.trusted_server.candidate_id` — resolves → forwarded candidate, provenance `mediator`, **price authoritative from the mediator, every render-source and notification field from the stored candidate, deal @@ -837,28 +939,34 @@ versions and is invalid if any differ). Browser timing: `performance.mark("tsjs:bids-script")` → `performance.mark("tsjs:first-display")`; reference fixture; warm cache; local resources; 5 warm-ups, 50 samples, nearest-rank p90 ≤ baseline × -1.10; inconclusive (3-run agreement > 5%) → one rerun, then fail. **Peak -JS heap, reproducibly:** via the Playwright CDP session — +1.10; inconclusive (3-run agreement > 5%) → one rerun, then fail. +**Retained-heap budget (named accurately — this measures retained, not +transient allocation peak):** via the Playwright CDP session — `HeapProfiler.collectGarbage` then `Runtime.getHeapUsage`, sampled at five fixed points (post-boot, post-adInit, post-first-render, post-refresh, post-SPA-navigation) on the maximal vector; metric = max -sample; gate ≤ baseline × 1.10; same rerun rule. Server benchmark: the -G5 lookup path; 100 warm-ups, 1,000 iterations; median + p90 one-sided -≤ baseline × 1.10; 3-run 5% agreement or inconclusive. +retained sample; gate ≤ baseline × 1.10; same rerun rule. (Transient +allocation peak is out of scope; if a future leak needs it, add +`HeapProfiler` continuous sampling as a separate budget.) Server +benchmark: the G5 lookup path; 100 warm-ups, 1,000 iterations; median + +p90 one-sided ≤ baseline × 1.10; 3-run 5% agreement or inconclusive. ### 7.11 Toolchain TypeScript floor to the resolved 5.9 line; release-gating flags `strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `noImplicitOverride`, -`useUnknownInCatchVariables`; checked-in npm script `typecheck`: +`useUnknownInCatchVariables`. The gate is a **checked-in package.json +script**, `"typecheck": "tsc -p tsconfig.json --noEmit"`, invoked so the +lockfile-resolved compiler is used: ``` -cd crates/trusted-server-js/lib && npx --no-install tsc -p tsconfig.json --noEmit +npm --prefix crates/trusted-server-js/lib run typecheck ``` -Dev toolchain bumps as individual CI-gated PRs; `prebid.js` excluded -from casual bumps; monthly review. +(the script runs `tsc` from the package's own `node_modules/.bin`, so no +`npx` path ambiguity). Dev toolchain bumps as individual CI-gated PRs; +`prebid.js` excluded from casual bumps; monthly review. ## 8. Rollout @@ -871,12 +979,31 @@ resample assignment ids, 2,000 resamples, fixed seed recorded in the gate artifact, strata (publisher × slot) weighted by control-arm traffic share; one-sided 95% confidence bounds on **relative differences**; each gate is an independent go/no-go (no cross-gate -multiplicity correction — stated). Missing telemetry counts as failure. -Per-flow floors; rare flows (direct, fallback) gate hermetically + -real-GAM, never statistically. `cycle_unattributable` divides by all -attribution-candidate TS cycles. **Billing cohort dimension in external -reports:** TS-driven GAM requests set a `ts_arm=` key-value, -making the arm a reportable GAM dimension for reconciliation. +multiplicity correction — stated). **"Missing telemetry counts as +failure" is made enforceable for whole-missing traces:** the server +writes a **sampled exposure row** (`ts_expected_attempts`, one per +server-observed eligible APS win in a sampled trace) at auction time; +gates **left-join client attempts to expected rows**, so a trace whose +client events never arrive is a visible missing attempt (not an absent +row that silently shrinks the denominator). A per-window +client-transport completeness ratio below 90% makes the statistical gate +**inconclusive** rather than passing on a biased sample. Per-flow floors; +rare flows (direct, fallback) gate hermetically + real-GAM, never +statistically. `cycle_unattributable` divides by all +attribution-candidate TS cycles. + +**Billing is the one gate the cluster bootstrap cannot run**, because GAM +aggregate reports expose only per-`ts_arm` totals, not session clusters. +Two options, one chosen per DR: (a) source billing from **GAM Data +Transfer / impression-level logs**, which carry the `ts_arm` key-value +**and** a joinable impression/`attempt_id` correlator, and run the same +cluster bootstrap over impression rows; or (b) if Data Transfer is +unavailable, use a **separate pre-reviewed aggregate estimator** with the +**day** as the randomization unit (a two-sample non-inferiority test over +daily per-arm RPM across the billing window), declared in the gate +artifact. Whichever is chosen, `ts_arm` is a **reserved, network-audited +key proven untargeted by any production line item and A/A-validated +before canary** (§0, §11), so it cannot perturb the metric it measures. **Phase 0 decision records:** DR-1 mediator presence; DR-2 script creatives (deployment decision); DR-3 #997 vs re-merge; DR-4 @@ -941,32 +1068,81 @@ Hermetic CI blocks PRs; the real-GAM suite is release-gating (A.3). | Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness; kill-switch snapshot semantics | | Perf | marks present; three vectors; heap CDP; inconclusive-rerun; pinned-environment baseline validity | -## 10. Alternatives considered - -Revision 8's twelve rejections stand (patch-without-telemetry; -always-direct-render; shared chunks now; big-bang rewrite; dropping the -bootstrap; timeout fallback; timeout re-arm; N/N−1 machinery; -client-computed hashes; fingerprint identities; plain cohort cookie; -`billing_outcome`), plus: **13.** injected in-realm ADM reporter — -rejected (hands bidder code the acceptance credential); owner-observed -`load`/`error` instead. **14.** cookie-routed CSP affinity — rejected -(opaque-origin reports carry no cookie); path-identity routing. **15.** -token-identity alone for arm attribution — rejected (authorization is -not a row dimension); arm-specific datasources + stamped union view. -**16.** indefinite diagnostic renewal — rejected; `dexp` ceiling. -**17.** shared attempt identity for parent/child — rejected; distinct -`attempt_id`. - -## 11. Risks - -Revision 8's register stands (cutover blast radius; sticky-cohort -infrastructure; mediator wire-contract change; published notification -triggers; required-config startup errors; beacon abuse; memory bounds; -advisory CSP; sink blindness; ABI freeze; schema generation), plus: the -**observation-only control build** is new scoped work whose "zero -behavior change" property is itself gated (hermetic parity vs baseline); -and `assignment_id` persistence is pseudonymous but new — bounded by the -24 h token TTL and excluded from any identity join by schema review. +## 10. Alternatives considered (complete) + +1. **Patch APS point-failures without telemetry** — rejected: four correct + fixes produced no reliable ads; the next would be another guess. +2. **Always direct-render APS** (skip GAM/PUC) — rejected: unilaterally + changes GAM reporting/pacing; kept only as the attributed-`gam_empty` + fallback. +3. **Single module graph / shared chunks now** — rejected for this release: + changes the delivery pipeline while everything else changes; successor + option behind the same registry surface. +4. **Full rewrite in one branch without phases** — rejected: the + browser-spec safety net is thinnest exactly where behavior changes. +5. **Dropping the ES5 bootstrap** — rejected: loses the pinned no-bundle + guarantee; the generated fallback keeps it. +6. **Timeout-triggered fallback rendering** — rejected: uncancelable GPT + requests race late fills → double-render/double-bill. +7. **Timeout-based quarantine re-arm** — rejected: recreates the + stale-event bug. +8. **N/N−1 compatibility machinery** — removed by the §0 policy decision. +9. **Client-computed notification hashes** — rejected: no key without + breaking the pseudonymization boundary; server-minted `notif_id`. +10. **Fingerprint identities for id-less bids** — rejected: can merge + distinct demand; rejection with closed reasons instead. +11. **Plain readable cohort cookie** — rejected: dark-pool opt-in + + cache-cardinality abuse; opaque authenticated token. +12. **`billing_outcome` event** — removed: no honest post-accept producer. +13. **Injected in-realm ADM reporter** — rejected: hands bidder code the + acceptance credential; owner-observed `load`/`error` instead. +14. **Cookie-routed CSP affinity** — rejected: opaque-origin reports carry + no cookie; server-selected `policy_id` path identity. +15. **Token-identity alone for arm attribution** — rejected: authorization + is not a queryable row dimension; the router injects a trusted internal + cohort header ingest stamps (§0). +16. **Indefinite diagnostic renewal** — rejected: signed `dexp` ceiling. +17. **Shared attempt identity for parent/child** — rejected: distinct + `attempt_id`. +18. **24 h affinity for a multi-day experiment** — rejected: the affinity + lifetime now covers the maximum experiment window (§0). +19. **`Image()` notification fallback** — rejected: it constructs a + separate credentialed/referrer-bearing request outside the primary + transport's privacy contract; a failed dispatch reports `failed` + instead (§G4d). + +## 11. Risks (complete register) + +- **Hard-cutover blast radius** — accepted by policy; bounded by the §0 + runbook (probes, low-weight canary, monitored window, weight-back). +- **Sticky-cohort routing is new infrastructure** the cutover depends on — + Phase 0 work; its coherence and affinity-lifetime tests are + release-gating. +- **Mediator wire-contract change** (`candidate_id` echo) — DR-4 gates + `merge_highest_cpm`; config validation enforces the block. +- **Published notification triggers** become a contract for PBS-path + demand — changing them later breaks SSP reporting. +- **Required `[auction].currency`/`winner_selection`** in mediated + deployments are a deliberate startup-error class under §0. +- **Beacon abuse** — pre-parse caps, per-family origin policies, + fail-closed numeric limits, signed modes, credentialed diagnostics. +- **Registry/limiter memory** — explicit capacities, TTL reclamation, + reject-at-capacity; Fastly overshoot documented, not claimed. +- **CSP data is advisory** — never a sole automatic rollback signal. +- **Sink blindness** — per-datasource authenticated probes. +- **ABI freeze** — `tsjs._internal.registry` is load-bearing; exact-release + verdicts are the contract. +- **Schema generation** — checked-in artifacts + staleness CI. +- **Observation-only control build** is new scoped work whose "zero + behavior change" property is itself gated by hermetic parity vs baseline + **and an A/A pre-canary** (the `ts_arm` GAM key must move no metric). +- **`assignment_id` persistence** is pseudonymous but new — bounded by the + affinity token lifetime and excluded from any identity join by schema + review; it reaches telemetry only via the router-injected trusted header + (§0), never a client field. +- **`ts_arm` GAM targeting key** could itself perturb line-item matching — + reserved and audited network-wide, proven untargeted by production line + items, and A/A-tested before canary (§8). ## 12. Success criteria @@ -1024,29 +1200,29 @@ decision records. Low volume: inconclusive → extend once → Hold. ### A.1 Phase gates -| Phase | Gate | Artifact | Numerator / denominator (source) | Floor | Threshold | Window | Owner | Action | -| ----- | ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- | --------------- | -------------------------------------------------- | ------------------------- | ----- | -------- | -| 0 | Dark-pool health | `probe-pool.sh` | expected responses / probe requests | 1,000 | 100% | 24 h | OPS | Hold | -| 0 | Schema validation | `schema-writes.sh` | accepted / deterministic synthetic rows (all tables) | 10,000 | **0 rejections** | 24 h | RO | Hold | -| 0 | Asset identity | `asset-probe.sh` | correct status / probed hashes | all | 100% | once | QA | Hold | -| 0 | Config binding | `config-hash.sh` | verified pools / pools | all | 100% | once | OPS | Hold | -| 1 | Kernel/bootstrap (hermetic) | `bootstrap-ownership.spec` + counters | passing cases / cases (no beacon dependency) | all | 100% | CI | QA | Hold | -| 2 | Ingest HTTP parity | `ingest-parity.sh` | passing / parity cases (4 adapters × 4 families) | all | 100% | CI | QA | Hold | -| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | accepted probe events / sent (probe tokens) | 10,000 | ≥ 99%; dedup exactly-once | 24 h | OPS | Hold | -| 2 | Per-sink authenticated probes | `gate_probes.pipe` | on-time probe rows / expected (`probe_run_id×seq`), per datasource × sink-backed adapter | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | -| 2 | Alert drill | `alert-drill.sh` | alerts ≤ 1 h / injected failure episodes | 3 episodes | 100% | 24 h | OPS | Hold | -| 3 | Funnel ssat/prebid/page_bids | `gate_funnel.pipe` | per A.2 stage pairs (`ts_render_attempts_v` ⋈ slot-level auction rows) | 10,000/flow-arm | per A.2 | 24 h | RO | Rollback | -| 3 | Direct/fallback conformance | hermetic + A.3 rows | passing / suite cases | all | 100% | CI + RG | QA | Hold | -| 3 | Attribution soundness | `gate_cycles.pipe` | `cycle_unattributable` / **all attribution-candidate TS cycles** | 10,000 | < 0.5% | 24 h | RO | Rollback | -| 3 | GAM fill | `gate_fill.pipe` | nonempty `slotRenderEnded` / TS request cycles, canary vs control | 10,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 24 h | RO | Rollback | -| 3 | Latency | `gate_latency.pipe` | p95 of (`render_terminal{accepted}.t_rel_ms − attempt_started.t_rel_ms`) per arm | 10,000/arm | 1-sided 95% CB rel. diff ≤ +2% | 24 h | RO | Rollback | -| 3 | Billing | `gate_billing.pipe` + GAM `ts_arm` | revenue per 1,000 attempts per arm (GAM report ⋈ attempts) | 100,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 7 d (+7 d ext.) | RO | Rollback | -| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` + reconciliation | duplicate `notification_sent{burl}` per `notif_id` / dispatches; GAM-vs-server deltas | 1,000 | 0 observed; reconciliation within 1% | 24 h / billing wnd | RO | Rollback | -| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | -| 4 | Four-flow parity | `flow-parity.spec` | passing / parity cases | all | 100% | CI | QA | Hold | -| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / §7.10 tolerances | CI | QA | Hold | -| 5 | RC re-canary | all Phase-3 rows on the attested RC | as Phase 3 | as Phase 3 | as Phase 3 | **each row's own window** | RO | Rollback | -| 5 | Cutover monitor | `gate_slis.pipe` | probe freshness/loss; `render_terminal{failed}` rate vs pre-cutover canary | — | lag ≤ 5 min; loss < 0.1%; failed ≤ canary + 0.5 pt | 24 h | OPS | Rollback | +| Phase | Gate | Artifact | Numerator / denominator (source) | Floor | Threshold | Window | Owner | Action | +| ----- | ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------- | ----- | -------- | +| 0 | Dark-pool health | `probe-pool.sh` | expected responses / probe requests | 1,000 | 100% | 24 h | OPS | Hold | +| 0 | Schema validation | `schema-writes.sh` | accepted / deterministic synthetic rows (all tables) | 10,000 | **0 rejections** | 24 h | RO | Hold | +| 0 | Asset identity | `asset-probe.sh` | correct status / probed hashes | all | 100% | once | QA | Hold | +| 0 | Config binding | `config-hash.sh` | verified pools / pools | all | 100% | once | OPS | Hold | +| 1 | Kernel/bootstrap (hermetic) | `bootstrap-ownership.spec` + counters | passing cases / cases (no beacon dependency) | all | 100% | CI | QA | Hold | +| 2 | Ingest HTTP parity | `ingest-parity.sh` | passing / parity cases (4 adapters × 4 families) | all | 100% | CI | QA | Hold | +| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | accepted probe events / sent (probe tokens) | 10,000 | ≥ 99%; dedup exactly-once | 24 h | OPS | Hold | +| 2 | Per-sink authenticated probes | `gate_probes.pipe` | on-time probe rows / expected (`probe_run_id×seq`), per datasource × sink-backed adapter | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | +| 2 | Alert drill | `alert-drill.sh` | alerts ≤ 1 h / injected failure episodes | 3 episodes | 100% | 24 h | OPS | Hold | +| 3 | Funnel ssat/prebid/page_bids | `gate_funnel.pipe` | per A.2 stage pairs (`ts_render_attempts_v` ⋈ slot-level auction rows) | 10,000/flow-arm | per A.2 | 24 h | RO | Rollback | +| 3 | Direct/fallback conformance | hermetic + A.3 rows | passing / suite cases | all | 100% | CI + RG | QA | Hold | +| 3 | Attribution soundness | `gate_cycles.pipe` | `cycle_unattributable` / **all attribution-candidate TS cycles** | 10,000 | < 0.5% | 24 h | RO | Rollback | +| 3 | GAM fill | `gate_fill.pipe` | nonempty `slotRenderEnded` / TS request cycles, canary vs control | 10,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 24 h | RO | Rollback | +| 3 | Latency | `gate_latency.pipe` | p95 of (`render_terminal{accepted}.t_rel_ms − attempt_started.t_rel_ms`) per arm | 10,000/arm | 1-sided 95% CB rel. diff ≤ +2% | 24 h | RO | Rollback | +| 3 | Billing | `gate_billing.pipe` + GAM `ts_arm` | revenue per 1,000 attempts per arm (GAM report ⋈ attempts) | 100,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 7 d (+7 d ext.) | RO | Rollback | +| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` + reconciliation | duplicate `notification_sent{burl}` per `notif_id` / dispatches; GAM-vs-server deltas | 1,000 | 0 observed; reconciliation within 1% | 24 h / billing wnd | RO | Rollback | +| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | +| 4 | Four-flow parity | `flow-parity.spec` | passing / parity cases | all | 100% | CI | QA | Hold | +| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / §7.10 tolerances | CI | QA | Hold | +| 5 | RC re-canary | all Phase-3 rows on the attested RC | as Phase 3 | as Phase 3 | as Phase 3 | **each row's own window** | RO | Rollback | +| 5 | Cutover monitor | `gate_slis.pipe` | probe freshness/loss; per-flow `render_terminal{failed}` rate vs pre-cutover canary | 10,000 attempts/flow | lag ≤ 5 min; loss < 0.1%; per-flow failed ≤ canary + 0.5 pt (flow-weighted; a below-floor flow holds, not passes) | 24 h | OPS | Rollback | ### A.2 Expected stages per flow @@ -1060,16 +1236,17 @@ decision records. Low volume: inconclusive → extend once → Hold. ### A.3 Real-GAM suite (attested) -| Field | Value | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | -| **Input** | **the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}`** | -| **Output** | **attested report embedding the manifest** — a green run attests the exact build it exercised, parameterized by (release, pool, deployment epoch) | -| Topologies | one per A.2 flow; publisher-overlap; disabled-initial-load formation; same-class supersession | -| Browsers | Chromium, Firefox, WebKit (CSP/opaque rows); Chromium (funnel rows) | -| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | -| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | -| Command | `npx playwright test --config real-gam.config.ts` | -| Artifact | Playwright HTML report + trace zips, retained 90 days | -| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | -| Approval evidence | green attested run URL in the release checklist, signed off by RO | +| Field | Value | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | +| **Input** | **the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}`** | +| **Independent verification** | **the workflow does not trust its input**: it fetches the deployed pool's runtime `release_id`/`config_hash` from a trusted control-plane endpoint, hashes the actually-served bundle bytes, verifies the deploy-provider binary provenance and the pool/epoch, and **compares all of them to the manifest — any mismatch fails the run** (so a green run cannot claim manifest X while exercising deployment Y) | +| **Output** | **OIDC-backed signed provenance** embedding the verified (release, pool, deployment epoch), not a caller-supplied echo | +| Topologies | one per A.2 flow; publisher-overlap; disabled-initial-load formation; same-class supersession | +| Browsers | Chromium, Firefox, WebKit (CSP/opaque rows); Chromium (funnel rows) | +| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | +| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | +| Command | `npx playwright test --config real-gam.config.ts` | +| Artifact | Playwright HTML report + trace zips, retained 90 days | +| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | +| Approval evidence | green attested run URL in the release checklist, signed off by RO | From 1b9753efa01a7985ae8ea804b1997a3cf3b3a7b4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:48:20 -0700 Subject: [PATCH 012/194] docs: finalize APS and TSJS resilience design --- ...8-04-aps-tsjs-resilience-implementation.md | 2737 +++++++++++ ...s-render-fix-and-tsjs-resilience-design.md | 4359 ++++++++++++----- 2 files changed, 5859 insertions(+), 1237 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md new file mode 100644 index 000000000..05c88d6cf --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -0,0 +1,2737 @@ +# APS Render Fix and TSJS Resilience Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` (recommended) or +> `superpowers:executing-plans` to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. Use +> `superpowers:test-driven-development` for each behavior change and +> `superpowers:verification-before-completion` before claiming a phase or the plan +> complete. + +**Goal:** make APS render deterministically across SSAT, Trusted Server Prebid, +page-bids, direct auction, and fallback paths while replacing TSJS's duplicated +global state with one bounded, lifecycle-owned runtime. + +**Architecture:** one composition root constructs a core runtime from injected +adapters and services. Integration IIFEs register as release-matched integration +modules, prepare inertly, and activate together behind one synchronous commit +barrier. Rust emits one bounded tagged render-source union and one exact per-slot +auction decision set. Universal Creative 1.17.2 supplies the outer response and +owner-registration channels; the kernel owns control and APS document channels. +Direct and PUC paths settle through the same terminal state machine. + +**Tech Stack:** Rust (`error-stack`, `http`, `serde`), lockfile TypeScript, Vitest, +Playwright, GPT/Prebid test adapters, Viceroy, and the existing four runtime +adapters. + +--- + +**Source of truth:** +`docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md` +revision 27, frozen review SHA +`6ed7fd4bafa31fe3a8112ad03ae5c600954d7568e6fef7ceabea5c9f8f94ab69`. This is the +only implementation-plan document for the work. APS render +and the runtime architecture are one coupled cutover: neither subsystem is useful or +safe to release independently, so they remain in this one plan. + +## Scope guardrails + +- Do not change analytics, persistence, billing, experimentation, or deployment + routing. Those are separate specifications. +- Do not add compatibility aliases, a runtime selector, or dual old/new protocols. + New construction is reachable only from test code until the coordinated switch. +- Keep unrelated publisher integrations behaviorally unchanged. Their only planned + changes are thin integration-module registration and shared-runtime access. +- Follow `CLAUDE.md`: use adapter-specific Cargo aliases, `error-stack`, `log`, and + descriptive `expect("should ...")`; never use bare `cargo test --workspace`. +- Every task follows red → green → refactor: add the narrow failing test, run it to + prove the failure, implement the minimum complete contract, rerun focused tests, + then run the task's regression commands. +- Do not create additional design or plan files. Implementation fixtures, schemas, + tests, and workflow edits listed below are implementation artifacts, not extra + plans. +- Never check APS runner bytes, a runner version/digest/license/metadata record, SRI, + an updater, or an offline runner fallback into source, tests, evidence, or release + artifacts. The only positive runner route is the live fixed-target proxy at + `/integrations/aps/runner.js`; `/integrations/aps/runner/v1.js` is negative-only. + This plan defines no runner cache behavior. + +Every task ends with `git status --short`, focused verification, and one intentional +commit before the next task. Stage only the exact paths from that task's **Files** +list that the implementation changed; never use broad staging in a dirty worktree. +Use the task title as the commit subject, normalized to the repository's conventional +`test:`, `feat:`, `refactor:`, or `chore:` prefix. Task 19's coordinated production +switch is one atomic commit; do not split it into deployable half-states. + +## Planned source shape + +The exact split may be adjusted during implementation only when it preserves these +owners and dependency directions. + +```text +crates/trusted-server-js/lib/src/ + kernel/ + identity.ts navigation-prefix + u64 attempts; 128-bit CSPRNG tickets/nonces + disposable.ts owned disposer stack and terminal latch primitives + integration_registry.ts release-matched prepare/activate transaction + runtime.ts bootstrap ownership and shared Runtime object + sessions.ts RuntimeSession and NavigationSession + adapters/ + googletag.ts only GPT-global access + prebid.ts only Prebid-global access + messaging.ts exact/versioned global and MessagePort envelopes + services/ + context.ts runtime-owned auction-context contributors + slots.ts SlotRecord indexes, request intents, physical cycles + projections.ts immutable navigation projection admission/commit + targeting.ts owner-aware GPT targeting journal and restoration + reservations.ts live renderer capabilities, WinnerContext, tombstones + render.ts RenderAttempt state machine and path drivers + auction_batch.ts shared fetch and child cancellation + integrations/ + aps/render.ts descriptor validation and static-renderer client + gpt/index.ts thin GPT composition + prebid/index.ts thin Prebid composition + core/ + index.ts final public API installation + request.ts input validation and AuctionBatch entry point + types.ts public and wire types + composition/ + browser.ts sole construction root for concrete adapters and services +``` + +Test files mirror source ownership under `crates/trusted-server-js/lib/test/`. +Do not make `kernel/` depend on `adapters/`, `services/`, or `integrations/`. +Only `composition/` may import every layer and construct concrete dependencies. + +## Dependency order + +```text +descriptor contract + -> server admission/mediation/projection + -> renderer endpoint + +kernel primitives + -> sessions + adapters + -> slot/reservation services + -> lifecycle + auction batch + -> GPT/Prebid/direct/fallback migration + +server path + browser path + -> hermetic browser matrix + -> real-GAM conformance + -> legacy deletion and hard cutover +``` + +## Coordinated-switch rule + +Tasks 1–18 build contracts, pure serializers/parsers, services, integration modules, +and test-only composition without switching a shipped page onto an incompatible +half-state. In particular: + +- Task 3 creates canonical projection/response serializers and parsers exercised + directly in tests; production `/auction`, initial HTML, and page-bids stay on their + current shapes until Task 19; +- Task 5 builds the versioned handler and exercises adapter parity through test-only + registries while production dispatch remains unchanged until Task 19; +- Task 8 computes and exposes release metadata but does not emit a required-integration + manifest or claim production bootstrap ownership; +- Tasks 16–18 produce tested integration-module preparation/activation and composition without changing the + shipped entry-point side effects. + +Task 19 is the single coordinated production switch: initial HTML/page-bids, core, +GPT, Prebid, APS, fallback, and every enabled integration begin using the new +projection, manifest, versioned renderer, and integration-module runtime together. No runtime +flag, deployment router, or operator-visible old/new selector is introduced. Task +22 removes the now-unreachable old surfaces before any release candidate is built. +Every task's regression suite therefore remains green in task order. + +## Phase 0 — pin the contract and baseline + +### Task 0: Establish the focused baseline and scope checks + +**Files:** + +- Modify: `crates/trusted-server-js/lib/package.json` +- Modify: `crates/trusted-server-js/lib/tsconfig.json` +- Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Create: `crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs` +- Create: `crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json` +- Create: `crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts` +- Modify: `scripts/integration-tests-browser.sh` +- Create: `scripts/dispatch-workflow-run.mjs` +- Create: `crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs` +- Create: `crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs` +- Modify: `.github/workflows/test.yml` +- Modify: `.github/workflows/integration-tests.yml` +- Test: existing Rust, Vitest, and APS Playwright suites + +- [ ] **Step 1: Write and run the failing executable adoption-manifest test.** Extract + the `rcjuly-tsjs-manifest-v1` JSON block from the revision-27 spec, enumerate + every `includeRoot` and exact file at + `905984e62a0858c53d9f0ff6dd3a1bf190cf311d` with `git ls-tree`, and fail for an + unmapped pinned file, a `lib/src` file mapped only to `RCJ-QUAL-01`, a dead + mapping, or a manifest/ledger id mismatch. Pin the expected audit result at 144 + files, 38 mapping rows, and 23 ledger/manifest ids so a moving baseline cannot + enter implementation silently: + + ```bash + node --test crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs + ``` + + Expected before the script exists: FAIL. Expected after the minimal extractor and + checker: PASS with zero unmapped/dead/gap arrays. Add the same command to CI and run + it before every phase exit. + +- [ ] **Step 2: Record baseline results before behavior changes:** + + ```bash + cargo test-fastly + cargo test-axum + cargo test-cloudflare + cargo test-spin + npm --prefix crates/trusted-server-js/lib ci + npm --prefix crates/trusted-server-js/lib test + npm --prefix crates/trusted-server-js/lib run build + ``` + + Any pre-existing failure is recorded in the execution notes; it is not silently + attributed to this work. + +- [ ] **Step 3: Keep the lockfile-resolved compiler; dependency upgrading is not part of this** + work. Add the checked-in `typecheck` script: + + ```json + "typecheck": "tsc -p tsconfig.json --noEmit" + ``` + + Enable `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, + `verbatimModuleSyntax`, `noImplicitOverride`, and + `useUnknownInCatchVariables`. Add the CI invocation with the lockfile-resolved + compiler. Add both `typecheck` and the existing architectural `lint` command to + CI. + +- [ ] **Step 4: Before behavior changes, make bundle measurement deterministic and record the** + minimal/reference/maximal gzip and Brotli bytes, Node/npm/TypeScript versions, + Chromium version, CI machine class, fixture, five warmups, 50 samples, p90 + boot-to-first-display, and forced-GC CDP heap checkpoints. Commit these values to + `aps-tsjs-prechange.json`; later tasks may compare against it but must not + regenerate it from the completed implementation. + + Extend `scripts/integration-tests-browser.sh` with + `TS_BROWSER_FRAMEWORKS=nextjs` and use `npm --prefix ... exec -- playwright` for + argument-safe invocation. The script remains the clean-checkout fixture builder: + it builds release Fastly WASM with the integration environment, generates + Viceroy configuration, builds/loads the framework image, installs browser + dependencies, and builds both TSJS fixture variants. Run the new performance + test itself—not only the bundle script—and write its 50-sample/heap output to the + exact baseline path: + + ```bash + TS_BROWSER_FRAMEWORKS=nextjs \ + TSJS_PERF_MODE=baseline \ + TSJS_PERF_OUTPUT=crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json \ + ./scripts/integration-tests-browser.sh \ + tests/shared/tsjs-performance.spec.ts --project=chromium + node crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs --baseline-only + ``` + + The integration workflow runs the same focused command on its pinned CI image and + uploads the resulting JSON. Add required manual input `evidence_id` and include it + in `run-name`. `dispatch-workflow-run.mjs` validates that the ref is a pushed + branch/tag, dispatches with a unique evidence id, polls for exactly that run, and + prints its numeric run id. Record the successful id in the baseline JSON: + + ```bash + TASK0_REF="$(git branch --show-current)" + TASK0_SHA="$(git rev-parse HEAD)" + test -n "$TASK0_REF" + git fetch origin "$TASK0_REF" + test "$TASK0_SHA" = "$(git rev-parse "origin/$TASK0_REF")" + TASK0_EVIDENCE_ID="aps-tsjs-baseline-$TASK0_SHA" + TASK0_RUN_ID="$(node scripts/dispatch-workflow-run.mjs \ + integration-tests.yml "$TASK0_REF" \ + evidence_id="$TASK0_EVIDENCE_ID")" + gh run watch "$TASK0_RUN_ID" --exit-status + test "$TASK0_SHA" = "$(gh run view "$TASK0_RUN_ID" --json headSha --jq .headSha)" + ``` + +- [ ] **Step 5: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib run typecheck + npm --prefix crates/trusted-server-js/lib run lint + node --test crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs + test -s crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json + ``` + +### Task 1: Make the APS descriptor a cross-language executable contract + +**Files:** + +- Modify: `crates/trusted-server-core/src/auction/types.rs` +- Modify: `crates/trusted-server-core/src/auction/formats.rs` +- Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts` +- Modify: `crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.json` +- Create: `crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json` +- Create: `crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json` +- Create: `scripts/generate-aps-renderer-contract.mjs` +- Create: `crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js` +- Create: `crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts` +- Create: `crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs` +- Modify: `crates/trusted-server-js/lib/package.json` +- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts` + +- [ ] **Step 1: Add failing corpus tests in Rust and Vitest for:** + - required versus optional exact keys; + - UTF-8 byte limits, not JavaScript character counts; + - exact numeric/integral dimensions at 0/1/4096/4097 and distinct + `invalid_dimensions` versus `dimensions_out_of_range` results; + - HTTPS URL, credentials, publisher-origin rejection, and URL byte limit; + - canonical standard base64 and decoded 256 KiB limit; + - UTF-8 decode failure and malformed JSON; + - exactly one seat/bid and exact nested keys; + - duplicated-field disagreement; + - nonfinite, negative, and wrong-type price; + - unknown descriptor version/type/tag type. + +- [ ] **Step 2: Run the focused tests and confirm at least one new adversarial vector fails in** + each implementation: + + ```bash + cargo test-fastly aps_renderer + npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/aps/render.test.ts + ``` + +- [ ] **Step 3: Add `generate:aps-contract` and `check:aps-contract` scripts. The generator reads** + the neutral schema/corpus and writes both named generated validator files. The + check runs the generator in staleness mode and fails on any diff. The Node test + executes the exact ES5 file embedded by `aps.rs` in a `vm` for every corpus + vector; it is not allowed to substitute the TypeScript validator. + +- [ ] **Step 4: Implement `BidRenderSourceV1`/the equivalent Rust enum with exactly `aps`, `adm`,** + and `cache` members. Ensure APS has only `ApsRendererV1`; delete alternate APS + `adm`, `meta`, or debug reconstruction. Align upstream and descriptor bid ids to + 1–64 UTF-8 bytes with no NUL/control. Define shared + `RENDER_DIMENSION_MIN = 1` and `RENDER_DIMENSION_MAX = 4096`; apply the same + noninteger/nonpositive versus out-of-range distinction in Rust, TS, generated + ES5, ADM/cache sources, programmatic sizes, and later DOM construction. No + validator or adapter may clamp. + +- [ ] **Step 5: Rerun the focused commands plus:** + + ```bash + npm --prefix crates/trusted-server-js/lib run typecheck + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run check:aps-contract + node --test crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs + ``` + +### Phase 0 exit + +- The baseline is known. +- Strict TypeScript is a checked-in CI command. +- Rust, TypeScript, and embedded ES5 agree on every APS descriptor vector. +- No external observability or experiment artifact is planned or created. + +## Phase 1 — make the server APS path deterministic + +### Task 2: Harden APS admission and typed drop reasons + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Modify: `crates/trusted-server-core/src/auction/provider.rs` +- Modify: `crates/trusted-server-core/src/auction/types.rs` +- Modify: `crates/trusted-server-core/src/auction/formats.rs` +- Modify: `crates/trusted-server-core/src/auction/orchestrator.rs` + +- [ ] **Step 1: Add failing tests for missing, duplicate, and >64-byte upstream bid ids;** + malformed contextual data isolated to a bid when safe; missing/invalid + `creativeurl`; invalid `tagtype`; non-HTTPS/self-origin URL; exact 1–4096 + dimension membership and reason mapping; default-off and enabled script + creatives; invalid/nonfinite price; + and one invalid sibling beside one valid bid. + +- [ ] **Step 2: Replace stringly ad-hoc reasons with one typed APS/auction drop-reason enum used** + exhaustively by provider response parsing and publisher debug projection. Do not + add a persistence or external-event failure reason. + +- [ ] **Step 3: Validate per bid before constructing `ApsRendererV1`. Preserve the exact accepted** + AAX bid in the encoded projection and cross-check it against the descriptor. + +- [ ] **Step 4: Treat a provider currency that violates the existing auction/provider contract as** + `invalid_provider_response`. Do not introduce a currency-specific public failure, + add or change auction configuration, add currency requirements, or alter non-APS + behavior. A slot with zero eligible providers is exactly + `failed{reason:'slot_not_eligible'}`. + +- [ ] **Step 5: Run:** + + ```bash + cargo test-fastly integrations::aps + cargo test-fastly auction + cargo test-axum + ``` + +### Task 3: Make per-slot outcomes explicit and mediation provenance-safe + +**Files:** + +- Modify: `crates/trusted-server-core/src/auction/orchestrator.rs` +- Modify: `crates/trusted-server-core/src/auction/types.rs` +- Modify: `crates/trusted-server-core/src/auction/formats.rs` +- Modify: `crates/trusted-server-core/src/auction/provider.rs` +- Modify: `crates/trusted-server-core/src/auction/endpoints.rs` +- Modify: `crates/trusted-server-core/src/integrations/adserver_mock.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Modify: `crates/trusted-server-js/lib/src/core/auction.ts` +- Modify: `crates/trusted-server-js/lib/test/core/auction.test.ts` + +- [ ] **Step 1: Add failing server and TS parser tests proving every requested slot receives** + exactly one ordered `winner | no_bid | failed` decision. Cover provider launch, + transport, timeout, HTTP, parse, per-bid validation, mediation failure, selected + winner projection failure, one provider failure beside another provider winner, + all-provider no-bid, partial slots, duplicate/extra/missing decisions, and the + exact closed failure priority from the spec, including the direct + `identity_generation_failed` wire result. + + Add exact `BrowserAuctionProjectionV1` boundary cases: 0/256/257 results and bids; + auction id `^[A-Za-z0-9._:-]{1,128}$`, exact 12-character base64url candidate, + provider `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, upstream id 1–64 UTF-8 bytes, + slot 1–256 UTF-8 bytes with no NUL/control, exact unique `r1_` reservation, finite + nonnegative CPM, literal `USD`; targeting 31/32/33 entries, exact + `[A-Za-z0-9_]{1,20}` keys at 19/20/21 characters, + 39/40/41-scalar and 159/160/161-byte values, control/unpaired-surrogate rejection, + reserved `hb_adid`, duplicate joins, and accessors/prototypes. Measure canonical + schema-order/request-order/lexically-sorted-targeting JSON just below/at/above + `8 * 1024 * 1024` bytes. + +- [ ] **Step 2: Normalize every provider response to exactly one internal `ProviderSlotOutcome`** + per dispatched slot. Produce `AuctionDecisionSetV1` once in the orchestrator. + Add pure serializers/parsers for `/auction` + `ext.trusted_server.slot_results` and `BrowserAuctionProjectionV1`, exercised by + direct unit tests while preserving the current internal return API. Do not yet + wire them into production `/auction`, initial HTML, or page-bids; their exact + coordinated switch occurs in Task 19. A winner must join exactly one projected + bid by exact slot plus candidate id; no-bid/failed joins none. + + When a complete canonical projection exceeds 8 MiB, transactionally convert every + winner decision to `failed{reason:'winner_not_renderable'}`, emit zero projected TS + winner bids, retain existing no-bid/failed decisions in request order, and remove + the corresponding `/auction` TS `seatbid` entries. Never choose a first-fit subset + or emit a partial projection. Prove the reduced result is bounded and deterministic + under response-order permutations. + +- [ ] **Step 3: Introduce internal provenance `(provider_name, upstream_bid_id)` and a** + response-unique opaque 12-character base64url `candidate_id` from 9 CSPRNG bytes; + test eight response-local collision retries and terminal `internal_error`. Store + candidates before mediation; put the id only at + `ext.trusted_server.candidate_id`; require the mock/configured mediator to echo + exactly one known id. Take only the selected price/selection metadata from + mediation and restore every render, identity, dimension, currency, and + notification field from the stored source candidate. Reject missing/unknown/ + duplicate echoes, substitutions, and mediator-native render sources. + +- [ ] **Step 4: Preserve the repository's configured mediator selection and timeout fallback.** + Preserve direct highest-CPM selection, adding only the deterministic + `(provider_name, upstream_bid_id)` tie break. Add response-order permutation tests + and prove opaque ids/arrival order never break ties. + +- [ ] **Step 5: Define and test the exact `/auction` winner wire. Standard `bid.id` is the** + `r1_` renderer reservation; `bid.impid` maps exactly to the server slot; and + `bid.ext.trusted_server` has only `candidate_id`, `slot_id`, and + `render_source`. Require the four-way decision/candidate/impid/slot join. Reject + missing, duplicate, extra, or mismatched bids; APS/cache standard `adm`; and ADM + disagreement between standard `adm` and `render_source.adm`. TSJS renders only + the tagged source and never reconstructs it from standard fields. + + The producer and parser must both deny unknown keys and enforce the same projection + field/count/byte grammars before any slot, reservation, targeting, or bid mutation. + Boot maps malformed/oversized input to `abi_mismatch`; page-bids/direct admission + maps it to `invalid_response` with no partial state. + +- [ ] **Step 6: Do not modify `auction_config_types.rs`, `auction/config.rs`, settings, TOML, or** + auction documentation unless compilation reveals an existing type reference that + must be renamed for the tagged render-source union. This task adds no new + `winner_selection`, currency, or mediator-fallback requirement. + +- [ ] **Step 7: Run:** + + ```bash + cargo test-fastly orchestrator + cargo test-fastly formats + cargo test-fastly publisher + cargo test-axum + npm --prefix crates/trusted-server-js/lib test -- --run test/core/auction.test.ts + ``` + +### Task 4: Project one tagged render source and exact renderer identity + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/auction/formats.rs` +- Modify: `crates/trusted-server-core/src/auction/types.rs` +- Modify: `crates/trusted-server-core/src/integrations/gpt.rs` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Modify: `crates/trusted-server-js/lib/src/core/config.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` + +- [ ] **Step 1: Add failing server tests for SSAT boot bids, page-bids, and `/auction`. Each must** + preserve the exact `BidRenderSourceV1`, `candidateId`, and server-minted renderer + reservation plus the finite nonnegative selected CPM that later becomes the + internal `WinnerContext`. Browser projection exposes `rendererReservationId`; + `/auction` uses the same value as standard OpenRTB `bid.id`. CPM is never copied + into a render descriptor or capability. + +- [ ] **Step 2: Add failing browser tests proving every TS-owned APS, ADM, and cache PUC source** + uses the `r1_` reservation byte-for-byte as GAM `hb_adid`. For Trusted Server + Prebid, replace the generated TS bid `adId` with that same reservation before + targeting; keep native Prebid `adId` untouched. Preserve PBS Cache UUID only as + `renderSource.cacheId` and the exact fetch query binding, never as bridge + authority. Add negative tests for truncation, fallback to upstream/cache ids, and + native-bid mutation. + +- [ ] **Step 3: Add Rust generation tests for `r1_` plus 22 unpadded base64url characters from** + 16 CSPRNG bytes, response-local uniqueness, eight collision retries, and + `identity_generation_failed`. Make projection choose identity from a tagged + enum/path decision, not an `or_else` chain. Reject invalid targeting before + serialization; never truncate. + +- [ ] **Step 4: Add the immutable `CacheFetchPolicyV1` boot contract. When cache rendering is** + enabled, project the trusted configured HTTPS base URL at + `tsjs.boot.cachePolicy`, validate/freeze it before integration-module preparation, and build + `fetchUrl` server-side with exactly one canonical `uuid` query. Test credentials, + query, fragment, origin/port/path mismatches, duplicate query keys, missing policy, + and configuration mutation after the navigation snapshot. This is cache render + correctness only; do not add a cache subsystem or runner caching. + +- [ ] **Step 5: Limit this task to wire projection and pre-mutation validation. Attempt-owned** + compare-and-restore targeting cleanup is implemented only after `RenderAttempt` + exists in Tasks 13 and 16. + + This task does not publish the new initial/page-bid projection or mutate a live + Prebid/GPT bid. Those production mutations begin atomically in Task 19 after the + reservation store and integration-module runtime exist. + +- [ ] **Step 6: Run:** + + ```bash + cargo test-fastly publisher + npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt/ad_init.test.ts test/integrations/prebid/index.test.ts + ``` + +### Task 5: Serve the static renderer and live APS runner proxy with adapter parity + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Modify: `crates/trusted-server-core/src/integrations/registry.rs` +- Modify: `crates/trusted-server-core/src/platform/http.rs` +- Modify: `crates/trusted-server-core/src/platform/test_support.rs` +- Modify: `crates/trusted-server-core/src/platform/types.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/platform.rs` +- Modify: `crates/trusted-server-adapter-fastly/Cargo.toml` +- Modify: `crates/trusted-server-adapter-axum/src/app.rs` +- Modify: `crates/trusted-server-adapter-axum/src/platform.rs` +- Modify: `crates/trusted-server-adapter-axum/tests/routes.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/src/platform.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/Cargo.toml` +- Modify: `crates/trusted-server-adapter-cloudflare/tests/routes.rs` +- Create: `crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml` +- Modify: `crates/trusted-server-adapter-spin/src/app.rs` +- Modify: `crates/trusted-server-adapter-spin/src/platform.rs` +- Modify: `crates/trusted-server-adapter-spin/Cargo.toml` +- Modify: `crates/trusted-server-adapter-spin/tests/routes.rs` +- Create: `crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml` +- Create: `crates/trusted-server-integration-tests/fixtures/configs/viceroy-aps-runner-proxy-template.toml` +- Modify: `crates/trusted-server-integration-tests/Cargo.toml` +- Create: `crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs` +- Create: `crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs` +- Modify: `crates/trusted-server-integration-tests/tests/common/mod.rs` +- Create: `crates/trusted-server-integration-tests/tests/environments/spin.rs` +- Modify: `crates/trusted-server-integration-tests/tests/environments/mod.rs` +- Modify: `crates/trusted-server-integration-tests/tests/environments/cloudflare.rs` +- Modify: `crates/trusted-server-integration-tests/tests/environments/fastly.rs` +- Modify: `crates/trusted-server-integration-tests/tests/parity.rs` +- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts` +- Create: `crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js` +- Create: `scripts/integration-tests-aps-runner-proxy.sh` +- Modify: `scripts/integration-tests-browser.sh` +- Modify: `.github/workflows/integration-tests.yml` + +- [ ] **Step 1: Write failing route and exact renderer-policy tests.** + + Cover enabled `GET /integrations/aps/renderer/v1` and + `GET /integrations/aps/runner.js`; APS-disabled local `404 no-store`; local + negative `404 no-store` for `/integrations/aps/runner/v1.js`, unknown renderer + versions, and malformed family paths; `405` plus `Allow: GET`; and proof that no + reserved path reaches publisher auth, EC, or fallback. Assert renderer body bytes, + the exact ordered sandbox tokens, the exact CSP from spec §3.6, exact content type, + immutable cache policy, `nosniff`, and referrer policy. Assert the deliberate + absence of `X-Frame-Options` and CSP `frame-ancestors`. + +- [ ] **Step 2: Run the new focused tests and prove they fail.** + + ```bash + cargo test-fastly integrations::aps + cargo test-axum --test routes + cargo test-cloudflare --test routes + cargo test-spin --test routes + ``` + + Expected: the live runner route/raw proxy policy and exact renderer headers are not + yet implemented on every adapter. + +- [ ] **Step 3: Define the bounded raw-proxy platform contract.** + + Add a dedicated request/response policy in `platform/http.rs` and adapter + implementations that: + - sends only credential-free `GET` to the compile-time fixed URL + `https://client.aps.amazon-adsystem.com/prebid-creative.js` with + `Accept-Encoding: identity`, no forwarded browser/publisher headers, no referrer, + and redirect following disabled; + - exposes `ProxyResponseEvidenceV1`: status plus every occurrence of the three + security-relevant headers when the runtime preserves pairs, or the exact visibly + combined value when it does not. Combined values are never split and every + comma/list form fails the singleton grammar; erased/ambiguous evidence is + `unavailable`, never reconstructed; + - returns a bounded stream or bounded buffer with a five-second monotonic deadline + from dispatch through the final body byte, cancellation on timeout/overflow, and + generation-inert late continuations; and + - uses the common `APS_RUNNER_MAX_RESPONSE_BYTES = 8 MiB` cap. + + Cloudflare must inspect the initial Workers headers before its generic adapter + strips encoding/length; concatenated duplicates remain visibly combined and fail. + Spin must bypass `spin_sdk::http::send`, call the WASI HTTP outgoing handler with + supported request options, and poll both response and body stream against a + monotonic-clock total deadline. Axum and Fastly must enforce the same deadline, + evidence, redirect, encoding, and cap contract instead of their generic client + defaults. If a runtime cannot supply the required evidence or cancellation + behavior, APS cannot be enabled there and the release is blocked. + +- [ ] **Step 4: Write and pass the complete actual-adapter proxy corpus.** + + Drive each real transport boundary—including Cloudflare and Spin wasm and full + Fastly routes—against a controlled fictional upstream. Cover status other than + 200; redirects; stall and slow-drip total deadlines; late data after cancellation; + absent/duplicate/malformed/mismatched/over-limit `Content-Length`; + absent/identity/listed/other `Content-Encoding`; missing/duplicate/parameterized/ + rejected `Content-Type`; invalid UTF-8; exactly-at and one-byte-over 8 MiB bodies; + buffered and streamed overflow; byte-preserving success; stripped upstream + cookies/headers; and empty non-leaking `502 no-store` failures. Do not inject an + already-normalized core response in place of an adapter transport. + + Build a hermetic `aps_runner_proxy` runtime test, not a core fake. Its private + control socket selects the next fictional upstream response out-of-band; browser + requests cannot choose a scenario or target. The request entering core always + carries the compile-time APS logical URL. An integration-test-only platform + resolver maps only that exact logical origin to the loopback fixture below target + validation and immediately above the real adapter transport. Fastly uses a + generated Viceroy backend, Cloudflare uses a workerd service binding in the + dedicated Wrangler manifest, Spin uses the dedicated Spin manifest, and Axum uses + its real bounded client. Production constructors expose no resolver/override, and + release-build absence tests fail if the integration feature, fixture address, or + service binding is enabled or embedded. + + `scripts/integration-tests-aps-runner-proxy.sh` builds the actual Fastly + `wasm32-wasip1`, Cloudflare `wasm32-unknown-unknown`, and Spin `wasm32-wasip1` + artifacts with that integration-only transport seam; launches Viceroy, + `wrangler dev`/workerd, and `spin up`; waits for readiness; runs the identical + `aps_runner_proxy` corpus against each runtime with `--test-threads=1`; and always + terminates process groups. The test asserts the logical URL and Host remain the + fixed APS target, the fixture is loopback-only, and actual runtime header + normalization, cancellation/resource drop, streaming cap, and + dispatch-through-final-byte clock produce the expected response. + +- [ ] **Step 5: Implement the reserved dispatcher and live proxy response.** + + Register the family ahead of auth/EC/fallback through one explicit test-only + registry constructor used by unit tests and the dedicated integration artifacts. + `scripts/integration-tests-browser.sh` accepts `TS_TEST_APS_V1=1` only to build that + non-release artifact; ordinary production dispatch stays unchanged until Task 19. + CI/release absence checks reject the integration feature/sentinel in a production + bundle, so this cannot become a hidden dual route. + Accept only status 200, the exact closed content-type/encoding/content-length + grammars from spec §3.6, a body within 8 MiB, and exact UTF-8 bytes. Relay accepted + bytes unchanged while replacing all headers with exactly the specified + JavaScript content type, wildcard CORS, cross-origin CORP, `nosniff`, and + no-referrer policy. Every upstream or validation failure returns a local empty + `502 no-store`, with no vendor body or descriptor/capability data in logs. + +- [ ] **Step 6: Implement and test the static renderer contract.** + + The renderer validates/clears the fragment nonce, accepts one exact source-bound + parent port, validates the descriptor and kernel-captured publisher origin, and + resolves only its own absolute `/integrations/aps/runner.js`. Before loading the + script, queue the exact `prebid/creative/render` event with one-shot + `resolve`/`reject`. Set only `crossOrigin='anonymous'` and + `referrerPolicy='no-referrer'`; do not set integrity/SRI. Script `load` is + nonterminal progress. Proxy/CORS/script-load failure reports `runner_no_load`; + callback rejection reports `runner_failed`; callback success reports completion. + The renderer starts no completion timer—the kernel owns the only ten-second timer + from document acceptance. Mutable APS callback correctness is an accepted external + trust dependency, not a fact TS can derive from script load or body inspection. + +- [ ] **Step 7: Add the hermetic fictional runner fixture.** + + Author a minimal local fixture that implements only the documented event and + queue/resolve/reject behavior. Assert it is neither a copy, transformation, nor + derivative of APS bytes. Use it for deterministic success, rejection, script-load, + callback-silence, nested-iframe, and duplicate-callback tests. The fixture is not + served as a production fallback and cannot be included in release bundles. + +- [ ] **Step 8: Run the full route, transport, parity, and browser checks.** + + ```bash + cargo test-fastly + cargo test-axum + cargo test-cloudflare + cargo test-spin + cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity + ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum + ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly + ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare + ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin + TS_TEST_APS_V1=1 TS_BROWSER_FRAMEWORKS=nextjs TS_BROWSER_PROJECTS=chromium \ + ./scripts/integration-tests-browser.sh \ + tests/shared/aps-renderer.spec.ts --project=chromium + ``` + +- [ ] **Step 9: Commit the transport and renderer slice.** + + ```bash + git add \ + crates/trusted-server-core/src/integrations/aps.rs \ + crates/trusted-server-core/src/integrations/registry.rs \ + crates/trusted-server-core/src/platform/http.rs \ + crates/trusted-server-core/src/platform/test_support.rs \ + crates/trusted-server-core/src/platform/types.rs \ + crates/trusted-server-adapter-fastly/src/app.rs \ + crates/trusted-server-adapter-fastly/src/platform.rs \ + crates/trusted-server-adapter-fastly/Cargo.toml \ + crates/trusted-server-adapter-axum/src/app.rs \ + crates/trusted-server-adapter-axum/src/platform.rs \ + crates/trusted-server-adapter-axum/tests/routes.rs \ + crates/trusted-server-adapter-cloudflare/src/app.rs \ + crates/trusted-server-adapter-cloudflare/src/platform.rs \ + crates/trusted-server-adapter-cloudflare/Cargo.toml \ + crates/trusted-server-adapter-cloudflare/tests/routes.rs \ + crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml \ + crates/trusted-server-adapter-spin/src/app.rs \ + crates/trusted-server-adapter-spin/src/platform.rs \ + crates/trusted-server-adapter-spin/Cargo.toml \ + crates/trusted-server-adapter-spin/tests/routes.rs \ + crates/trusted-server-integration-tests/Cargo.toml \ + crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml \ + crates/trusted-server-integration-tests/fixtures/configs/viceroy-aps-runner-proxy-template.toml \ + crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs \ + crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs \ + crates/trusted-server-integration-tests/tests/common/mod.rs \ + crates/trusted-server-integration-tests/tests/environments/spin.rs \ + crates/trusted-server-integration-tests/tests/environments/mod.rs \ + crates/trusted-server-integration-tests/tests/environments/cloudflare.rs \ + crates/trusted-server-integration-tests/tests/environments/fastly.rs \ + crates/trusted-server-integration-tests/tests/parity.rs \ + crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts \ + crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js \ + scripts/integration-tests-aps-runner-proxy.sh \ + scripts/integration-tests-browser.sh \ + .github/workflows/integration-tests.yml + git commit -m "feat(aps): proxy the live creative runner safely" + ``` + +### Phase 1 exit + +- Valid APS bids survive mediation and projection without identity loss. +- Invalid bids fail with exact local reasons. +- Test-only adapter registries prove the same secure static renderer and live runner + proxy behavior through every actual transport without activating a dual production + route. +- Existing non-APS auction and publisher tests remain green. + +## Phase 2 — build one TSJS runtime + +### Task 6: Enforce layering and external-global ownership + +**Files:** + +- Modify: `crates/trusted-server-js/lib/eslint.config.js` +- Modify: `crates/trusted-server-js/lib/package.json` +- Create: `crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js` +- Create: `crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs` +- Create: `crates/trusted-server-js/lib/src/adapters/googletag.ts` +- Create: `crates/trusted-server-js/lib/src/adapters/prebid.ts` +- Create: `crates/trusted-server-js/lib/src/adapters/messaging.ts` +- Create: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Create: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add failing lint-rule tests for direct and aliased access through `window`,** + `globalThis`, and `self`, plus aliases of `googletag`/`pbjs`. Add allowed cases + inside adapters and kernel `window.tsjs`/messaging code. + +- [ ] **Step 2: Configure `import/no-restricted-paths` for the dependency direction in the** + source-shape diagram. Add a narrow, enumerated temporary allowlist for current + production files that still violate the target (`core/request.ts`, GPT/Prebid + integration files, and diagnostics files found by the initial lint inventory). + New files receive no exemption. Check the allowlist into the lint test and make + Task 22 fail if any entry remains. + +- [ ] **Step 3: Add adapter interfaces and no-op/fake constructors before moving behavior. Add a** + composition root that alone imports concrete adapters/services. Kernel files + accept interfaces and never construct downstream objects. At this task's end + production remains behaviorally unchanged, while new kernel/service files cannot + touch GPT or Prebid globals. + + The messaging interface must support synchronous capture-phase listener + installation. Reserve that hook as the first reversible core activation so the + final core can install the TS capability recognizer before integration-module + activation and before TS-owned GPT/Prebid injection, while leaving no listener live + during asynchronous preparation. + +- [ ] **Step 4: Ensure `.github/workflows/format.yml` continues to run lint and** + `.github/workflows/test.yml` runs typecheck. Enforcement begins with the narrow + allowlist and becomes repository-clean in Task 22. + +- [ ] **Step 5: Run:** + + ```bash + node --test crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run typecheck + ``` + +### Task 7: Implement disposal, terminal latch, and transactional integration modules + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/kernel/disposable.ts` +- Create: `crates/trusted-server-js/lib/src/kernel/integration_registry.ts` +- Create: `crates/trusted-server-js/lib/test/kernel/disposable.test.ts` +- Create: `crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` + +- [ ] **Step 1: Write failing fake-timer tests for reverse-order exactly-once disposal, disposer** + failure isolation, registration after disposal, first-terminal-wins, duplicate + integration id, malformed/unknown manifest id, wrong 64-hex release, pending + capacity 16, prepare throw/async rejection/abort, detached continuation, + activation throw, duplicate `afterCommit`, shared deadline abort, and late + continuation. + +- [ ] **Step 2: Implement `DisposableStack` without relying on a browser proposal unavailable at** + the configured target. It must be synchronous at ownership boundaries; async side + work can observe its signal but cannot delay terminal disposal. + +- [ ] **Step 3: Implement the exact `BootManifestV1` contract and release-internal** + `_registerIntegration({id,release,prepare})` collection. All manifest entries are + required. Registration executes no module code. In manifest order, core awaits + only each returned preparation Promise; preparation may validate frozen config, + acquire interfaces, allocate inert private data, and register private-memory + disposers, but cannot touch globals/DOM, attach wrappers/listeners, inject/load, + start timers/network, invoke publisher code/stateful adapters, or detach work. + Quarantine wrong-release, unexpected, duplicate, and post-fallback bundles + before calling `prepare`. + +- [ ] **Step 4: Implement the synchronous activation barrier.** A prepared module + returns one synchronous `activate(ctx)`. Activation may install only + compare-restorable wrappers/listeners/observers/subscriptions, registering each + disposer before mutation. It may stage at most one `afterCommit` callback and + cannot yield, inject/load, start timers/network, drain a queue, or invoke + publisher callbacks. Activate in manifest order; on throw, unwind every + activated/prepared module in reverse order before fallback. Check the shared + monotonic deadline before and after every activation and immediately before + handoff: 9,999 ms may commit; 10,000/10,001 ms must unwind even if the timer task + has not run. Document/test that a permanently nonreturning same-thread function + is unpreemptable. A second `afterCommit` registration throws and becomes + `bundle_partial`. + +- [ ] **Step 5: Test post-commit behavior.** After all activations, publish the full + API, invoke staged callbacks in manifest order, then drain preload work. An + `afterCommit` throw disposes only that module, records a bounded local runtime + failure, and cannot roll back the kernel or create fallback. Race publisher GPT + calls and script/creative DOM activity before, during, and after a later module + failure; prove preparation is inert, activation is same-task, and no wrapper, + guard, script, request, listener, or timer survives a fallback commit. + +- [ ] **Step 6: Expose only frozen interfaces; keep mutable maps and owner tokens in closures.** + +- [ ] **Step 7: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/disposable.test.ts test/kernel/integration_registry.test.ts + npm --prefix crates/trusted-server-js/lib run typecheck + ``` + +### Task 8: Implement bootstrap ownership and the single runtime registry, dormant + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/kernel/runtime.ts` +- Create: `crates/trusted-server-js/lib/test/kernel/runtime.test.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` +- Modify: `crates/trusted-server-js/lib/src/core/index.ts` +- Modify: `crates/trusted-server-js/lib/src/core/queue.ts` +- Modify: `crates/trusted-server-js/lib/src/core/log.ts` +- Modify: `crates/trusted-server-js/lib/src/core/global.d.ts` +- Create: `crates/trusted-server-js/lib/test/core/queue.test.ts` +- Create: `crates/trusted-server-js/lib/test/core/log.test.ts` +- Create: `crates/trusted-server-js/lib/src/integrations/gpt/bootstrap_fallback.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts` +- Modify: `crates/trusted-server-core/src/integrations/gpt.rs` +- Modify: `crates/trusted-server-core/src/tsjs.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Modify: `crates/trusted-server-js/lib/package.json` +- Create: `crates/trusted-server-js/lib/scripts/print-release-id.mjs` +- Modify: `crates/trusted-server-js/build.rs` +- Modify: `crates/trusted-server-js/src/bundle.rs` +- Modify: `crates/trusted-server-js/src/lib.rs` + +- [ ] **Step 1: Add failing tests for field-wise initialization, existing publisher queue/config,** + two core loads, invalid manifests, wrong/duplicate/unknown integrations, + prepare/activate/after-commit failure at every checkpoint, a missing or hung + integration, the shared watchdog and monotonic activation checks, + owner-generation mismatch, late continuation after fallback, bundle after + fallback, and exactly-one committed owner. At each failure checkpoint assert the + exact immutable `abi_mismatch | bundle_partial` reason; FIFO draining despite a + throwing callback; queued and later `requestAds`; already-aborted signals; late + integration refusal without invoking module code; and zero runtime/service/adapter/ + listener/timer/port/iframe construction after the fallback commits. + + Exercise the queue boundary as a real Array: pushes before/during/at activation and + commit, retained ingress references, snapshot-versus-forward exactly once, nested + push ordering, `this === tsjs`, throw isolation, and non-callables. The final queue + has `length:0`, its own immediate `push`, and is frozen; native/borrowed mutators, + index/length assignment, deletion, and property definition in strict/sloppy callers + cannot retain work or change length. + +- [ ] **Step 2: Implement `unclaimed → installing → kernel` and** + `installing → failed → fallback`. Start the only ten-second watchdog immediately + before core injection; it covers registration, preparation, and activation for + every required integration. Combine the timer with `performance.now()`/the + injected monotonic clock checks before/after every activation and before + handoff. Abort completes synchronous reverse-order unwind before fallback. + Deferred bundles after fallback are rejected and cannot replace that generation. + Exercise this through the test-only composition harness; do not yet replace the + production bootstrap. + + Normalize `tsjs.que` to an actual ingress Array with a writable-false, + configurable-true property during preparation. After successful activation (or + after unwind for fallback), perform the spec §5.3 six-step synchronous handoff: + construct/freeze the final real Array, snapshot callable own data entries, clear + ingress and replace its `push` with a forwarder, redefine the public property + non-writable/non-configurable with all committed fields, run kernel `afterCommit` + callbacks, then drain the snapshot. No task/microtask may split the steps. + + The fallback is one terminal non-rendering shell, never a reduced runtime. Before + draining the queue it installs the final `requestAds` validator, immediate queue, + permanently refusing `_registerIntegration`, exact `version:'1.0.0'`/`releaseId`, + validating-then-refusing `addAdUnits`, local logger, immutable safe boot value, and + frozen non-enumerable `_internal` fallback record. It + constructs no runtime, registry, adapter, bridge, timer, listener, port, or + iframe. Batch membership comes only from exact server slot ids in the immutable + boot auction projection. Retain it only when the full §3.1–3.2 shape, grammar, + 256-slot, dimension, and 8 MiB bounds pass; otherwise substitute exactly + `{version:1,auction:{version:1,auctionId:'fallback',results:[]},bids:[]}` plus + creative/diagnostics disabled safe defaults. Known members resolve with the boot failure, unknown ids + resolve `slot_unresolved`, aborted known members cancel, and an omitted empty + projection resolves `slots:[]`. No valid call remains pending. + +- [ ] **Step 3: Make `build-all.mjs` emit all bundles with one fixed sentinel, compute 64** + lowercase SHA-256 hex over the canonical ordered ids plus sentinel-normalized + bytes, replace exactly one sentinel per bundle, and verify none remains. Embed + that release id in every bundle. Write exact generated + `crates/trusted-server-js/dist/tsjs-release-v1.json` with + `{version:1,releaseId,bundles:[{id,file}]}` in canonical bundle order; add + `npm run --silent print:release-id` to validate that file and print only its + 64-hex id. Extend `build.rs` generated metadata to include + the sentinel-normalized all-bundle `release_id`; expose it through `bundle.rs` and + the crate API beside bundle bytes/content hashes. Add Rust tests proving generated + metadata and every bundle carry the same id. Implement the pure + `BootManifestV1` serializer with exactly the enabled unique integration ids in actual + injection order and `required:true`, but do not emit it into production HTML yet. + Test changed logical bytes, reordered integrations, sentinel multiplicity, wrong + release, missing integration, and server/bundle disagreement. + +- [ ] **Step 4: Install one `Runtime`; the composition root keeps the mutable service** + registry and owner tokens in its closure, and integrations obtain frozen + interfaces only through exact-release preparation/activation contexts. + `tsjs._internal` is non-enumerable frozen status data only—never the registry. + + In test-only composition, activate the capture-phase bridge recognizer as the first + reversible core effect, followed by correctness GPT listeners and prepared modules. + Production bootstrap and manifest emission remain unchanged until Task 19. + +- [ ] **Step 5: Generate the proposed queue/boot-flags fallback artifact from** + `bootstrap_fallback.ts` and test its bytes and terminal behavior at every + checkpoint. Task 19 performs the one production replacement and adds the final + embedded staleness assertion; there is never a hand-maintained second fallback + implementation. + +- [ ] **Step 6: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/runtime.test.ts test/integrations/gpt/gpt_bootstrap.test.ts + cargo test-fastly release_id + cargo test-fastly + npm --prefix crates/trusted-server-js/lib run build + ``` + +### Task 9: Add runtime and navigation sessions + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/kernel/sessions.ts` +- Create: `crates/trusted-server-js/lib/src/kernel/identity.ts` +- Create: `crates/trusted-server-js/lib/test/kernel/sessions.test.ts` +- Create: `crates/trusted-server-js/lib/test/kernel/identity.test.ts` +- Create: `crates/trusted-server-js/lib/src/services/projections.ts` +- Create: `crates/trusted-server-js/lib/test/services/projections.test.ts` +- Create: `crates/trusted-server-js/lib/src/services/context.ts` +- Create: `crates/trusted-server-js/lib/test/services/context.test.ts` +- Modify: `crates/trusted-server-js/lib/src/kernel/runtime.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add failing tests for RuntimeSession singleton ownership, navigation replacement,** + reverse-order disposal inventory, late old-generation callbacks, same DOM id on a + new navigation, timer/listener/port cleanup, and double disposal. + + Add deterministic-crypto issuer tests proving one `NavigationSession` obtains an + eight-byte CSPRNG prefix and combines it with one big-endian unsigned 64-bit ordinal + stored as two u32 words. Each `a1_` encodes those 16 bytes as 22 unpadded base64url + characters; ordinal increments exactly once, never wraps, and needs no issued-id + history. Unavailable/throwing crypto or ordinal exhaustion fails + `identity_generation_failed` before creating work. Separately prove `t1_` and `n1_` + encode 16 fresh CSPRNG bytes; their eight-draw collision/capacity behavior belongs + to Tasks 14 and 13. Never pass raw bytes or issued identities to logging/debug + callbacks. + +- [ ] **Step 2: Implement explicit owner APIs:** + - `RuntimeSession`: injected adapter interfaces and owner/disposer scopes; it + does not import concrete adapters or services; + - `NavigationSession`: aliases, intents, targeting ownership, batches, attempts, + and one internal immutable current auction projection; + - `AuctionContextRegistry`: runtime-owned, manifest-bounded contributor callbacks + registered with owner-scoped disposers; it snapshots contributions for one batch, + preserves manifest registration order and later-key precedence, freezes the + result, and isolates/logs one contributor throw without retaining its values; + - child scopes for `AuctionBatch` and `RenderAttempt`. + + Concrete slot maps, physical cycles, reservations, and the bridge listener are + services constructed in `composition/browser.ts` and disposed by the runtime + scopes through interfaces. + + Seed only the initial session from recursively frozen + `tsjs.boot.auctionProjection`; never mutate boot. A new SPA session begins with no + projection. Its page-bids controller deep-copies/freezes one exact current- + generation `BrowserAuctionProjectionV1`, reserves all projected slots against the + shared 256 cap (including already-admitted programmatic units), and commits slots + + projection atomically. Stale/duplicate/malformed/over-cap responses commit nothing + and never retain prior-navigation data. + +- [ ] **Step 3: Every callback captures an owner generation and verifies it before mutation.** + Provide test-only inventory snapshots; do not expose mutable production state. + + Keep the issuer injectable only through test composition. Production composition + always uses browser Web Crypto; no `Math.random`, counter, timestamp, publisher + input, or compatibility-form parser may mint a capability. + +- [ ] **Step 4: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/sessions.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/identity.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/services/projections.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/services/context.test.ts + npm --prefix crates/trusted-server-js/lib run typecheck + ``` + +### Task 10: Implement bounded adapters and readiness queues + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/adapters/googletag.ts` +- Modify: `crates/trusted-server-js/lib/src/adapters/prebid.ts` +- Create: `crates/trusted-server-js/lib/test/adapters/googletag.test.ts` +- Create: `crates/trusted-server-js/lib/test/adapters/prebid.test.ts` +- Modify: `crates/trusted-server-js/lib/src/adapters/messaging.ts` +- Create: `crates/trusted-server-js/lib/test/adapters/messaging.test.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add failing tests for `present | pending | timed_out | incompatible`, late external readiness,** + per-operation timeout/removal, abort/disposal, queue capacity, command throw, + callback throw, exact message keys/version, zero/one/two ports, and port closure. + Race readiness immediately before/at/after the exact deadline. + +- [ ] **Step 2: Move global access behind injected adapter methods. `timed_out` describes one** + operation, not a permanent library state; `incompatible` describes only the + currently bound external object/stamp and later replacement may succeed. Each adapter + readiness queue holds at most 64 operations. Overflow fails the new operation + synchronously with `external_queue_full`; timed-out/aborted entries are removed + immediately and readiness drains live entries FIFO. Each operation expires with + `external_ready_timeout` exactly ten seconds from enqueue, independent of + `requestAds.timeoutMs` and the auction-fetch deadline. Dispatch versus expiry + races through one latch. + +- [ ] **Step 3: Centralize all message names, exact-shape guards, safe port extraction, and port** + disposal in the messaging adapter. Do not put reservation or lifecycle policy in + the adapter. + +- [ ] **Step 4: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/adapters + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run typecheck + ``` + +### Phase 2 exit + +- One runtime and registry exist across separately built IIFEs. +- Bootstrap/fallback ownership is transactional and generation-safe. +- Sessions and adapters own every external global and disposal boundary. +- No APS/GPT/Prebid production behavior has yet been switched without tests. + +## Phase 3 — move slot, auction, and render state into services + +### Task 11: Implement the slot registry and physical GPT cycle model + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/services/slots.ts` +- Create: `crates/trusted-server-js/lib/test/services/slots.test.ts` +- Create: `crates/trusted-server-js/lib/src/services/targeting.ts` +- Create: `crates/trusted-server-js/lib/test/services/targeting.test.ts` +- Modify: `crates/trusted-server-js/lib/src/kernel/sessions.ts` +- Modify: `crates/trusted-server-js/lib/src/adapters/googletag.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add failing tests for exact nonempty server slot ids at the 256-UTF-8-byte** + boundary with NUL/control rejection, combined 255/256/257 server plus + programmatic records, ad-unit codes, DOM aliases, alias + collision, GPT object identity, SRA, one active/one queued replacement, TS versus + publisher intent, `display()` under disabled initial load, same/opposite-class + supersession, ambiguous overlap, duplicate response identifiers, no timeout + re-arm, safe destroy/redefine, and navigation disposal. For disabled initial load, + prove TS `display()` only registers and exactly one + `refresh([slot],{changeCorrelator:false})` owns the intent; refresh unavailability/ + throw fails `gpt_request_failed`, while publisher display stays publisher-owned. + Include old completion + after navigation and before/after the replacement completion on the same DOM id. + Race `slotRequested` immediately before/at/after three seconds and + `slotRenderEnded` immediately before/at/after ten seconds from request start. + + Cover transactional destroy/redefine at every recovery site: throw/false destroy, + failed replacement definition, stale generation after define, and proof that no + second physical GPT slot or binding appears. Destroy failure retires/quarantines + the old identity and makes later TS work fail `gpt_request_failed` until publisher + destruction or reload. + +- [ ] **Step 2: Implement `SlotRecord`, navigation-local registration ordinals, and indexes.** + Reject missing/empty/over-limit server ids and exact server-id collisions before + indexing. Ad-unit and DOM alias resolution must produce exactly one record or + `slot_unresolved`; never normalize server identity or choose first registration. + Enforce one `MAX_ACTIVE_SLOT_RECORDS = 256` transaction across server projection + and later programmatic registration; navigation disposal releases all records. + +- [ ] **Step 3: Record intent before the adapter operation. Open a physical cycle only on** + `slotRequested`; close on `slotRenderEnded`; attribute only when exactly one live + compatible TS intent exists. Fail ambiguous TS ownership instead of guessing. + A request-capable GPT call with no `slotRequested` by three seconds fails + `gpt_request_timeout`; because no attributable cycle exists, immediately + invoke one adapter transaction that marks the old TS object retired, requires + successful `destroySlots([old])`, and only then defines/binds a replacement; or + place a publisher-owned object in permanent + page-lifetime quarantine until explicit publisher destruction/reload. Future GPT + events never release that request-timeout quarantine. An opened cycle with no + completion by ten seconds fails `gpt_completion_timeout`; only its matching real + completion may drain that exact cycle. Neither timeout starts fallback. + +- [ ] **Step 4: Expose narrow operations to integrations: register/adopt slot, record intent,** + handle GPT event, own/clear targeting, resolve exact slot, dispose navigation. + +- [ ] **Step 5: On navigation disposal, destroy/redefine only a TS-owned GPT slot object and keep** + the retired object in the `WeakMap` until late completion drains. Define a + replacement only when a current navigation needs it and the transactional + destroy succeeded. Quarantine an + open publisher-owned object until its real `slotRenderEnded`, publisher + destruction, or reload; new TS work fails `slot_quarantined`. No timeout or + navigation token re-arms a physical cycle. + +- [ ] **Step 6: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/services/slots.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/services/targeting.test.ts + npm --prefix crates/trusted-server-js/lib run typecheck + ``` + +### Task 12: Implement the bounded renderer reservation store + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/services/reservations.ts` +- Create: `crates/trusted-server-js/lib/test/services/reservations.test.ts` +- Modify: `crates/trusted-server-js/lib/src/kernel/sessions.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add failing tests for `r1_` reservation validation, exact identity lookup, duplicate** + insertion, atomic claim, duplicate simultaneous claim, consumed/stale/disposed + tombstones, the fixed 15-minute render boundary, the ten-second Prebid admission + lease, atomic selection promotion to a new 15-minute render expiry, unselected/ + aborted/selection-timeout tombstoning only through the original lease, union + capacity 320, suppression/refusal and contract-failure tombstoning of a PUC claim + against a pre-selection lease, refusal at capacity, no live eviction, and a late + request for the oldest unexpired id. Cover immutable finite/nonnegative + `WinnerContext{selectedCpm}`, exact Prebid-bid CPM equality, context preservation + across promotion and projection replacement, transfer into the attempt before + consumption, and context/source deletion from tombstones. Renderer-id generation/collision retry + belongs to Rust Task 4, not this browser store. + +- [ ] **Step 2: Move `apsPrebidRenderers` and consumed-id maps out of public globals into the** + runtime-owned service. Entries carry exact slot, tagged render source, navigation + generation, fixed expiry, state, attempt binding, and immutable winner context + while live. Source + `WindowProxy` is intentionally absent until the first valid PUC claim acquires + it. Consumption transfers the render source/context to the exact attempt before + replacing the entry. It never extends expiry; tombstones remain through original + expiry with only id/expiry/state/minimum suppression metadata. + +- [ ] **Step 3: Use one reservation type for every TS-owned APS, ADM, and cache PUC source.** + Validate the supplied server id but never generate it in the browser. Cache UUID + remains only `cacheId` transport state; upstream bid id remains provenance; + native Prebid ids never enter this store. Reject an id collision against any + live/tombstoned entry; lookup recognizes a TS id before detailed validation. + +- [ ] **Step 4: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/services/reservations.test.ts test/integrations/aps/render.test.ts + ``` + +### Task 13: Implement the RenderAttempt state machine and direct paths + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/services/render.ts` +- Create: `crates/trusted-server-js/lib/test/services/render.test.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts` +- Modify: `crates/trusted-server-js/lib/src/core/render.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add failing state-table tests for every valid transition and every invalid/replay** + transition. Race success, failure, timeout, caller abort, supersession, and + navigation disposal through one terminal latch. Add accepted-artifact promotion + races and prove terminal attempt disposal cannot remove a committed render. At + construction assert one exact navigation-unique `a1_` attempt id; fallback child + ids are distinct and bind their exact parent id. Test navigation-prefix failure, + ordinal exhaustion, disposal, and that neither ids nor issuer bytes reach logs. + + Add renderer-nonce live-registry tests at 255/256/257 entries, eight collision + draws, crypto failure, exact source/port/attempt/generation binding, disposal reuse, + and no tombstone/history set. Capacity maps `capability_registry_full`; exhausted + draws map `identity_generation_failed`. + +- [ ] **Step 2: Implement path-independent `RenderAttempt` ownership: state, exact slot,** + generation, exact `a1_` id, optional parent attempt id, tagged render source, + immutable `WinnerContext{selectedCpm}`, timers, ports, iframe, terminal result, + and disposer. Direct winner admission constructs the context from the exact + validated joined server winner; a PUC claim receives the same context from the + consumed reservation. + Obtain the id only from the NavigationSession issuer before registering work; an + issuance failure settles `identity_generation_failed` without DOM/global + mutation. Add `SlotOperation` above attempts so a primary and optional fallback + retain immutable child results while the operation exposes one final result and + `path`. + Transition methods—not callers—create and clear deadlines. Before acceptance, + atomically detach committed iframe/targeting/physical-slot metadata into one + slot/navigation-owned `CommittedRenderArtifact`; the attempt disposer removes + only uncommitted resources. On replacement, promote the new artifact, dispose + the prior artifact before publishing the new one, and rebase targeting ownership + without clearing the newer generation. Direct + iframe artifacts remove their DOM; PUC artifacts defer DOM ownership to GPT and + follow TS-owned destroy/redefine versus publisher-owned metadata-only rules. + +- [ ] **Step 3: Implement direct APS:** + - validate descriptor before DOM mutation; + - mint one exact `n1_` nonce from the 16-byte Web Crypto issuer and create the + inner channel immediately before insertion; bind the nonce to the exact attempt, + generation, renderer `contentWindow`, and retained port; + - put the nonce in the fragment and transfer an envelope containing the + kernel-captured publisher origin; + - bind to the exact iframe `contentWindow` and transferred port; + - atomically consume the nonce on the first valid document acceptance and + invalidate it on failure, supersession, navigation, or disposal; duplicate, + wrong-source, stale, or late use is inert and nonce values are never logged; + - start the document deadline at iframe insertion and fail + `renderer_document_no_load` at three seconds; + - make the kernel the sole owner of the ten-second APS-completion deadline, + starting only at document acceptance; the static renderer owns no competing + timer; + - map proxy/CORS/script-load failure to `runner_no_load`, callback rejection or + ten-second callback silence to `runner_failed`, treat `runner_loaded` only as + progress, and accept only `TS APS Render Completed`; remove on failure/cancel. + +- [ ] **Step 4: Implement direct ADM through one shared constructor used later by the PUC owner.** + Use the exact ordered sandbox, no-referrer policy, integral 1–4096 source dimensions, + CSS sizing, zero border/margin, hidden overflow, display, scrolling, title, and + aria attributes from spec §4.5. Create the iframe detached, install one-shot + handlers/disposal first, assign exactly one complete `srcdoc`, and append once; + never append empty or set `src`. Accept only the exact current pending frame's + intended `srcdoc` load while its generation/latch remain current. Initial + `about:blank`, pre-assignment, removed/replaced frame, stale generation, + post-disposal, duplicate load, error, and five-second timeout cannot accept and + map to `adm_document_no_load`. + + Implement cache as a preceding bounded fetch. Require a frozen valid + `CacheFetchPolicyV1`; require `baseUrl`/`fetchUrl` at the 4,096-byte boundary and + the server-built HTTPS URL to have the exact base origin/port/path and exactly + one canonical `uuid` query equal to `cacheId`; use `redirect:'error'`, omit + credentials/referrer, require CORS and a + successful status, and enforce five seconds and 512 KiB. Parse only a JSON object + with required own bounded nonempty `adm`; optional `w`/`h` must appear together, + stay in 1–4096, and match; optional finite nonnegative `price` is ignored; unknown OpenRTB bid + keys are ignored; raw bodies, aliases, arrays/primitives, and wrappers fail. + Expand only the exact `${AUCTION_PRICE}` token as + `String(attempt.winnerContext.selectedCpm)`; leave `${AUCTION_PRICE:B64}` untouched + and never read response `price`, current projection, targeting, or a later winner. + Test delayed PUC cache after projection replacement, Prebid lease promotion, and a + separate direct-cache context, plus URL/query/redirect/body/shape/macro cases, all + three typed cache failures, and proof none becomes `no_bid`. + +- [ ] **Step 5: Keep all remote side effects outside terminal correctness. APS has no synthetic** + notification. + +- [ ] **Step 6: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/services/render.test.ts test/core/render.test.ts test/integrations/aps/render.test.ts + ``` + +### Task 14: Implement Universal Creative claim and owner-control channels + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/services/render.ts` +- Modify: `crates/trusted-server-js/lib/src/services/reservations.ts` +- Modify: `crates/trusted-server-js/lib/src/adapters/messaging.ts` +- Create: `crates/trusted-server-js/lib/src/services/puc_bridge.ts` +- Create: `crates/trusted-server-js/lib/test/services/puc_bridge.test.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` +- Create: `crates/trusted-server-integration-tests/browser/fixtures/prebid-universal-creative-1.17.2.js` +- Create: `crates/trusted-server-integration-tests/browser/fixtures/prebid-universal-creative-1.17.2.sha256` + +- [ ] **Step 1: Vendor the exact supported PUC 1.17.2 artifact and checksum for hermetic tests;** + the GAM template pins the same version and never `latest`. Add failing tests for + the exact JSON string + `{message:"Prebid Request",adId,adServerDomain}`, object/extended shapes, + zero/two ports, native id, live/tombstoned TS id, duplicate simultaneous claim, + replay, prior navigation, SafeFrame-shaped nesting, outer post failure, and every + ordering of early claim, nonempty/empty GAM, navigation, supersession, claim + deadline, and GPT-cycle deadline. Include caller abort before/after registration, + insertion, and document acceptance; lost/closed control channel before start and + after insertion; settlement-post throw; remote cleanup at 19,999/20,000/20,001 + ms; and proof accepted remote DOM remains while every uncommitted failure removes + exactly its owned iframe and settles the PUC Promise once. + +- [ ] **Step 2: Install exactly one capture-phase bridge dispatcher synchronously as the first** + reversible core activation, before integration-module activation and any + TS-owned GPT/Prebid injection; asynchronous preparation leaves no listener. It + multiplexes only `Prebid Request` and + `TS Render Owner Register`; no attempt installs another global listener. First + perform side-effect-free minimal extraction of own data + `message`/`adId`/optional `lifecycleTicket` from JSON strings or clone-safe plain + objects without invoking accessors. For the request branch, lookup before exact + parsing or port checks. Leave non-TS ids to native Prebid; for live/tombstoned TS + ids call `stopImmediatePropagation()` immediately, including object/extended + shapes and zero/two ports, then exact-parse and generically refuse/close invalid + requests. The first valid claim acquires + `MessageEvent.source` as the authoritative PUC `WindowProxy`; never precompute or + walk the SafeFrame ancestry. + +- [ ] **Step 3: Implement the two-condition join. An early claim buffers only source plus outer** + response port and discloses no render data. A nonempty GAM result starts the + three-second claim timer when claim is absent. Empty/disposal/supersession closes + the port and tombstones. When both are present, atomically revalidate/consume, + mint an exact `t1_` ticket from 16 Web Crypto CSPRNG bytes through the Task 9 + issuer, with at most eight total draws and fixed three-second TTL, and + reply with the exact ready outer schema. Bind it to the exact attempt id, + reservation id, PUC source, and navigation generation; issuance failure settles + `identity_generation_failed` before any ready response. Refusals use the exact + generic refused schema. + The outer response carries only owner kind/ticket plus the checked-in dynamic + owner, never ADM, APS descriptor, nonce, or final document port. + + Store live tickets plus tombstones in one runtime-owned capacity-320 registry. + Prune expired entries first, never evict an unexpired entry, preserve the original + three-second expiry on consumption/disposal, and map capacity to + `capability_registry_full` and eight-draw collision exhaustion to + `identity_generation_failed` before exposing any usable capability. + + Bound a claim-first path by the GPT request-start/completion deadlines from Task + 11 and the attempt deadline. Test boundary races and prove only an attributable + completion-timeout event drains its exact physical cycle; request-timeout events + never release publisher quarantine. Neither can revive the claim or start + fallback. + +- [ ] **Step 4: In the hidden dynamic renderer call PUC's supplied** + `h.sendMessage('TS Render Owner Register',{version:1,lifecycleTicket},callback)`; + never global `postMessage`. Assert the kernel sees the original captured PUC + source, exact auto-added `adId/message` keys, and one helper-created response + port. Test exact registered/refused responses, ticket TTL/atomic consumption, + wrong source, stale generation, replay, zero/two response ports, the owner's + three-second watchdog, helper disposer, and late response. + + Receive registration through the same dispatcher. Minimally lookup the ticket + map first; ignore unknown tickets, but suppress live/tombstoned TS tickets before + exact source/adId/attempt/generation/shape/one-port checks. Refuse and close + recognized invalid/replayed registration, keep ticket tombstones through their + original TTL, and prove attempt disposal removes ticket state without removing + the runtime dispatcher. Atomically consume the first valid use, invalidate on + timeout/failure/supersession/navigation/disposal, make duplicate/stale/late uses + inert, and prove ticket values and issuer bytes never reach logs. + +- [ ] **Step 5: On registration the kernel creates the owner-control channel, keeps one endpoint,** + and transfers exactly one endpoint in `TS Render Owner Registered`. For APS it + then sends exact `TS APS Start` with the descriptor/envelope plus exactly one + renderer-document port. For ADM it sends exact `TS ADM Start` and no port. Add + exact-shape tests for every start, insertion, document progress, render + completion/failure, ADM load/failure, and final owner settlement message and for + every wrong/extra key or port count. `OwnerSettlementV1` cancellation includes + exactly `caller_aborted | superseded | navigation_disposed`; every terminal + RenderOutcome is therefore encodable after registration. + +- [ ] **Step 6: For APS, make the owner create exactly one iframe using the immutable renderer** + sandbox constant, meet the one-second insertion deadline, and leave document and + completion timing to the kernel anchors from Task 13. For ADM/cache, call the + exact shared detached-iframe constructor from Task 13; report `TS Owner Inserted` + only after its one append, then report only the intended load/failure. Initial + blank, replacement, removal, duplicate, stale, disposed, error, and timeout races + cannot resolve the PUC Promise. Resolve/reject that Promise only from the kernel's + final settlement. The remote owner—not the kernel—owns that iframe: accepted + settlement promotes it, removes temporary handlers, closes the port, and + resolves once; failed/cancelled settlement removes it, removes handlers, closes, + and rejects once. Arm one fail-closed 20-second settlement/channel watchdog when + registration accepts the control port, before start; never rearm it on start. + Malformed control, `messageerror`, local disposal, silent loss, or expiry runs + remote cleanup but cannot report acceptance to the kernel. Direct-path iframe + cleanup remains kernel-owned. The kernel owns all terminal decisions; bidder ADM + receives no capability. Prove each channel creator/retained/transferred endpoint + is closed by success, refusal, timeout, cancellation, and navigation tests. + +- [ ] **Step 7: Run the shared protocol corpus through both global and port parsers. Enforce the** + 4,096-byte inbound JSON cap before parse; exact 25-character capabilities; + field-specific 256/2,048/4,096-byte limits; safe generations; 64 KiB dynamic + owner and 72 KiB successful outer-response limits; exact keys/prototypes; and + boundary-minus-one/boundary/boundary-plus-one multibyte, duplicate-key, malformed + encoding, accessor, and exact 1/4096 dimension cases. + +- [ ] **Step 8: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/services/puc_bridge.test.ts test/services/render.test.ts test/integrations/gpt/ad_init.test.ts + ``` + +### Task 15: Implement the exact public API, programmatic registration, and `requestAds` + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/services/auction_batch.ts` +- Create: `crates/trusted-server-js/lib/test/services/auction_batch.test.ts` +- Modify: `crates/trusted-server-js/lib/src/services/context.ts` +- Modify: `crates/trusted-server-js/lib/test/services/context.test.ts` +- Modify: `crates/trusted-server-js/lib/src/core/request.ts` +- Modify: `crates/trusted-server-js/lib/src/core/auction.ts` +- Modify: `crates/trusted-server-js/lib/src/core/index.ts` +- Modify: `crates/trusted-server-js/lib/src/core/registry.ts` +- Modify: `crates/trusted-server-js/lib/src/core/log.ts` +- Modify: `crates/trusted-server-js/lib/src/core/queue.ts` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Modify: `crates/trusted-server-js/lib/src/core/global.d.ts` +- Modify: `crates/trusted-server-js/lib/src/index.ts` +- Modify: `crates/trusted-server-js/lib/test/core/request.test.ts` +- Modify: `crates/trusted-server-js/lib/test/core/auction.test.ts` +- Modify: `crates/trusted-server-js/lib/test/core/registry.test.ts` +- Modify: `crates/trusted-server-js/lib/test/core/log.test.ts` +- Modify: `crates/trusted-server-js/lib/test/core/queue.test.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add failing input tests for omitted/undefined options, null/array/non-object,** + foreign/accessor/unknown properties, invalid/duplicate/too-many slots, timeout + bounds, AbortSignal brand, exact server-slot-id selection, an unknown id beside a + valid sibling, ad-unit/DOM alias collisions, and input-order output. Prove an + omitted-slot call synchronously snapshots current NavigationSession registrations + by ordinal and excludes a later registration. Prove an omitted timeout uses + exactly 10,000 ms for only the shared auction fetch; readiness and renderer/GPT + deadlines neither inherit nor reset it. + +- [ ] **Step 2: Add failing public-surface and registration tests.** Assert the exact + kernel/fallback `TsjsApi` own properties, `version:'1.0.0'`, release equality, + recursively frozen `TsjsBootV1`, non-enumerable frozen status-only `_internal`, + permanently refusing `_registerIntegration`, diagnostics present only on kernel, + and no compatibility/placeholder/mutable-config aliases. Keep numeric `V1` only + on actually serialized boot/wire schemas; public helpers are `TsjsApi`, + `TsjsCommandQueue`, `TsjsLog`, and `TsjsDiagnostics`. Update the package barrel + to export only the final public names and remove old `AdUnit`/API aliases. + + Cover `addAdUnits` one/array, empty/257 units, unknown/accessor/prototype fields, + duplicate/colliding codes, malformed media/bids/params, encoded 256 KiB auction + body cap, 63/64/65-byte bidder names, integral dimensions at 0/1/4096/4097 with + exact `invalid_dimensions | dimensions_out_of_range`, and combined registry totals + 255/256/257. Registration is synchronous, all-or-nothing, navigation-scoped, and + assigns ordinals after server slots. Fallback fully validates then throws exact + `TsjsUnavailableError` without constructing a registry. + + Assert logger default level/methods, all valid levels, invalid-level throw without + mutation, bounded output, and missing/throwing console methods. Reuse Task 8's + frozen real-Array queue tests as the final public queue contract. + +- [ ] **Step 3: Implement `addAdUnits` before live mutation.** Validate the complete + input in deterministic field order, reserve capacity for the whole call, then + transactionally register exact programmatic `code` values into the same + navigation slot service. A code is a public registered slot id but never a GPT + path/DOM alias; successful units participate only in later direct-auction + snapshots and do not define/display/target GPT. + +- [ ] **Step 4: Replace the void/callback API with the exact Promise contract. Interpret every** + explicit entry only as an exact case-sensitive registered slot id—server or + programmatic—never a GPT ad-unit path, DOM id, or alias. Preserve explicit input order or omitted snapshot order in + the results and always return the exact server id. Input errors reject before + creating attempts. Unknown explicit ids resolve `slot_unresolved` without + blocking valid siblings. After attempt creation, operational/render failures + resolve as typed per-slot results. + +- [ ] **Step 5: Implement one `AuctionBatch` per fetch. Test partial overlap across concurrent** + calls, one child superseded, all children superseded, already-aborted caller, + later abort, response deadline, reversed response order, invalid response, + missing/duplicate/extra slot decisions, winner-to-bid join mismatch, server + failed decisions, and navigation disposal. Parse the exact standard `bid.id` / + `impid` plus exact three-key `bid.ext.trusted_server` contract and require the + decision/candidate/impid/slot join. Reject standard `adm` on APS/cache and any ADM + mismatch; never infer a render source from standard bid fields. + + Snapshot the runtime-owned auction-context contributors exactly once before request + serialization. Contributors are invoked in manifest registration order; later keys + retain the baseline precedence, one throw is locally logged/isolated, and module or + runtime disposal removes the contributor before the next batch. Navigation changes + do not duplicate or discard a document-scoped contributor. No integration imports + or mutates a module-global provider map. + +- [ ] **Step 6: Preserve error distinctions:** + `auction_timeout | network_error | http_error | invalid_response`. After parsing, + consume the server's exact per-slot decisions; only an explicit `no_bid` decision + is no-bid. Never infer no-bid from an absent/malformed sibling. + +- [ ] **Step 7: Exercise the exact `TsjsApi` through test-only composition. Keep the shipped public** + entry point unchanged until Task 19; Task 22 deletes the old callback/void overload + and declarations rather than keeping an alias. + +- [ ] **Step 8: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/services/auction_batch.test.ts test/core/request.test.ts test/core/auction.test.ts test/core/index.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/core/registry.test.ts test/core/log.test.ts test/core/queue.test.ts + npm --prefix crates/trusted-server-js/lib run typecheck + ``` + +### Phase 3 exit + +- Slot/cycle attribution, reservations, direct/PUC lifecycles, and auction batches + are service-owned and independently tested. +- Every created attempt has one terminal result under adversarial ordering. +- The new test-only public contract has no compatibility aliases; the shipped API + remains unchanged until the coordinated Task 19 switch. + +## Phase 4 — migrate integrations and remove duplicate state + +### Task 16: Prepare the GPT integration module over adapters, slots, and render services + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/adapters/googletag.ts` +- Modify: `crates/trusted-server-js/lib/test/adapters/googletag.test.ts` +- Modify: `crates/trusted-server-js/lib/src/services/slots.ts` +- Modify: `crates/trusted-server-js/lib/test/services/slots.test.ts` +- Modify: `crates/trusted-server-js/lib/src/services/targeting.ts` +- Modify: `crates/trusted-server-js/lib/test/services/targeting.test.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add or preserve failing tests for early unconditional GPT subscriptions,** + publisher services already enabled, SRA, disabled initial load, one refresh path, + `changeCorrelator:false`, responsive ambiguity, collapsed-shell guard, targeting + ownership, page-bids, SPA replacement, retired TS-owned slot objects, + publisher-owned cycle quarantine/drain, old completion races, and direct + publisher GPT activity. Include external-readiness, request-start, and completion + deadline boundaries and accepted-artifact replacement/navigation ownership. + Add the exact handoff corpus: exact and hydration-renamed late `defineSlot`, + mismatch/ambiguity, publisher duplicate `display`, explicit/global refresh with + disabled initial load, unrelated slot/options preservation, ownership transfer + racing navigation, and proof that publisher work never starts TS fallback. + + Add deterministic fake-timer DOM-reconciliation tests at 249/250 ms and + 4,999/5,000 ms for first/final pass success, ambiguity, expiry-versus-commit latch, + ownership transfer during a pass, one and two successful rebinds, immediate + `reconciliation_capacity` on a third disconnect, and navigation disposal. Exercise + request-timeout, completion-timeout, navigation, and reconciliation through the + same destroy/redefine transaction. A throwing/false `destroySlots([old])` retires + and quarantines the old identity, defines no second physical slot, and returns + `gpt_request_failed`; a failed replacement leaves the slot unbound. A stale + generation destroys any just-created TS replacement and cannot bind it. Prove no + path destroys a publisher-owned slot. + +- [ ] **Step 2: Extract a GPT integration module used by test-only composition.** Its + `prepare(ctx)` reads only validated frozen boot data and creates closures; it + performs no GPT/global/DOM/script/timer/listener mutation. Its synchronous + `activate(ctx)` installs every reversible adapter interception and registers + disposers before mutation. Script injection or other irreversible startup is + staged in the module's single `afterCommit` callback. Move every GPT-global + call into `GoogletagAdapter` and all slot/cycle state into `slots.ts`, while + retaining the shipped entry-point behavior until Task 19. + +- [ ] **Step 3: Route APS and ADM winners to `RenderAttempt`/PUC bridge. Remove duplicate renderer** + branches, slot expandos, local consumed-id maps, and independent refresh wrappers. + +- [ ] **Step 4: Implement the owner-and-value targeting journal in `services/targeting.ts`.** + Keep one closure-private stack per physical GPT slot/key. Each TS write pushes a + distinct frame containing its owner id, exact installed string, and predecessor + value/owner—even when the string is unchanged. The GPT adapter observes the live + slot's `setTargeting`, per-key `clearTargeting`, and clear-all calls. A private + reentrancy marker distinguishes TS calls; every publisher-originated mutation + invalidates the affected restoration chain before forwarding, including a + same-value write, without changing arguments, return value, throw behavior, or + ordering. + + Before a TS write, compare the actual GPT value to the current frame and discard a + stale chain rather than overwriting publisher state. Cleanup writes only when the + disposing frame is the current owner and the actual value still equals its + installed string. Removing a non-top frame performs no GPT call and rebases the + successor to the removed predecessor. Acceptance promotes frames into the + `CommittedRenderArtifact`; disposal of an older accepted artifact uses the same + non-top rebase. Test two equal-string generations under newer success, failure, + supersession, and older-artifact disposal, plus publisher different-value and + same-value set, per-key clear, clear-all, wrapper replacement, and throws at every + cleanup point. Never blind-clear. + + Implement the ordered publication transaction: validate source/slot/reservation, + register the live reservation, expose exact `hb_adid` and other targeting, record + GPT intent, then invoke the request. Any intervening failure tombstones the + reservation, compare-restores targeting, and settles. Prove a fast creative + request always finds the store entry. + +- [ ] **Step 5: Fold integration-specific script-guard mechanics onto the shared factory while** + keeping GPT configuration in its integration. Implement one runtime-owned + `MutationObserver` per `NavigationSession`, 250 ms debounce, 5,000 ms monotonic + window, one final boundary pass, the two-success cap, exact physical-object + quarantine, and complete timer/candidate/reference disposal. Successful handoff + cancels reconciliation and transfers cleanup ownership synchronously. + +- [ ] **Step 6: Run the entire GPT suite, not only new files:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt + npm --prefix crates/trusted-server-js/lib test -- --run test/adapters/googletag.test.ts test/services/slots.test.ts test/services/targeting.test.ts + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run typecheck + ``` + +### Task 17: Prepare Prebid and APS registration on the shared runtime + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/prebid_modules/aliases.d.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/prebid_modules/liveIntentIdSystem.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json` +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts` +- Modify: `crates/trusted-server-js/lib/src/adapters/prebid.ts` +- Modify: `crates/trusted-server-js/lib/test/adapters/prebid.test.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` +- Modify: `crates/trusted-server-js/lib/build-prebid-external.mjs` +- Modify: `crates/trusted-server-js/lib/test/build-prebid-external.test.mjs` +- Modify: `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs` + +- [ ] **Step 1: Add failing tests for the server `r1_` reservation replacing a Trusted Server** + Prebid bid's generated `adId` before targeting, exact equality with `hb_adid`, + exact ad-unit ownership, invalid descriptor, duplicate response, registration + capacity, navigation disposal, native non-TS bid/id preservation, and lifecycle + success/failure. Pin the exact content-addressed external artifact built from + lockfile-resolved Prebid.js 10.26.0 and its admission behavior: `not_admitted` + produces no bid/event/targeting state; throw and partial publication fail closed + at runtime; and all three map to the exact admission/contract failure reasons. + Cover selected versus losing TS bids, native winners, multiple ad units, exact + auction-id ownership, navigation/auction abort, missing/late `auctionEnd`, event + listener ordering before publisher callbacks, ten-second admission lease, and + capacity release. Prove a PUC request before selection is suppressed/refused, + tombstones the suppress-only lease, and maps to `prebid_contract_violation`. A + dependency-version change requires deliberate fixture review. + +- [ ] **Step 2: Extract a release-matched Prebid integration module used by test-only** + composition. `prepare(ctx)` is inert; `activate(ctx)` synchronously installs + only reversible adapter state and contributes at most one `afterCommit` + callback. Use the Prebid adapter for every global call and the RuntimeSession + reservation service for APS, ADM, and cache PUC entries. The adapter binds one + exact `pbjs` object plus its own recursively frozen artifact-stamp identity and + rechecks both on every operation. Missing or invalid bindings report + `incompatible` only for that readiness operation; later whole-object replacement + can satisfy later work. Keep shipped entry-point behavior unchanged until Task 19. + +- [ ] **Step 3: Remove `tsjs.apsPrebidRenderers`, Prebid function sentinels, and direct imports of** + GPT integration internals. Preserve eids and unrelated Prebid behavior through + existing tests. + +- [ ] **Step 4: Keep `aps/render.ts` responsible for descriptor/client renderer mechanics only;** + registry and lifecycle state live in services. + + Prepare and validate the complete bid, register the reservation, replace the TS + `adId`, then call the version-pinned adapter's single + `admitTrustedBid(preparedBid): admitted | not_admitted` boundary as the only + irreversible action. Atomic `not_admitted` and throw tombstone and settle + `prebid_admission_failed`; detected partial publication tombstones/suppresses, + settles `prebid_contract_violation`, and blocks the artifact gate. Never alter + native Prebid `adId` values. + + `admitted` enters `awaiting_prebid_selection` on a ten-second lease, not a render + attempt. The adapter's early synchronous `auctionEnd` listener queries exact + auction/ad-unit winners before publisher targeting callbacks, promotes only the + selected TS id and its immutable `WinnerContext` into a new 15-minute render + reservation/attempt, and tombstones all losing TS ids only through their original + short lease. Missing auction end records + `prebid_selection_timeout`; navigation/auction abort clears the admitted set. No + losing bid remains live for 15 minutes. + +- [ ] **Step 5: Make the external artifact independently correct and pure.** Build exactly + lockfile-resolved Prebid.js 10.26.0 with no TS auction, admission, render, + targeting, or refresh behavior. The first wrapper statement arms an independent + 5,000 ms queue-drain watchdog before stamp inspection or module factories. It + calls the then-current real object's idempotent `processQueue()` at most once per + wrapper so every publisher callback runs exactly once even when the TS module is + absent or incompatible. + + Expose only one own, non-enumerable, non-writable, non-configurable + `__trustedServerArtifactV1` data property containing the exact recursively frozen + `ExternalPrebidArtifactV1`. Same-release/content duplicates reuse the object without + re-executing factories; a different valid release refuses the new wrapper without + disturbing the working object. Absent, accessor, inherited, malformed, and hostile + non-configurable descriptors never throw or stop publisher Prebid; they only make + TS readiness incompatible and emit at most one bounded warning. Verify all manifest + sort/uniqueness/UTF-8/count/alias/config/EID bounds and exact version 10.26.0. + + Emit exactly one 64-zero release sentinel, hash the sentinel-normalized JavaScript, + replace it with the lowercase SHA-256 artifact release id, and separately compute + the final-byte SHA-256/SRI. Assert no sentinel remains and do not require the + artifact release id to equal the TSJS release id. Black-box tests bind and recheck + the exact `pbjs` plus stamp identities, cover late valid replacement, and prove the + external artifact contains no TS behavior or `window.__tsjs_*` handshake. + +- [ ] **Step 6: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid test/integrations/aps + npm --prefix crates/trusted-server-js/lib run build:prebid-external + npm --prefix crates/trusted-server-js/lib test -- --run test/prebid-artifact-integration.test.mjs + ``` + +### Task 18: Prepare creative, diagnostics, and remaining integration modules + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/click.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/iframe.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/image.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/datadome/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/datadome/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/didomi/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/google_tag_manager/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/lockr/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/osano/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/permutive/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/permutive/segments.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/sourcepoint/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/testlight/index.ts` +- Modify: `crates/trusted-server-js/lib/src/core/trace.ts` +- Modify: `crates/trusted-server-js/lib/test/core/trace.test.ts` +- Modify: `crates/trusted-server-js/lib/src/services/context.ts` +- Modify: `crates/trusted-server-js/lib/test/services/context.test.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/async.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/beacon_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/globals.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/origin.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/scheduler.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/test/shared/async.test.ts` +- Modify: `crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts` +- Modify: `crates/trusted-server-js/lib/test/shared/dom_insertion_dispatcher.test.ts` +- Modify: `crates/trusted-server-js/lib/test/shared/scheduler.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/creative/click.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/creative/helpers.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/creative/image.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/datadome/script_guard.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/osano/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/permutive/segments.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/sourcepoint/script_guard.test.ts` +- Create: `crates/trusted-server-js/lib/test/integrations/testlight/index.test.ts` +- Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/trace_cookie.rs` +- Modify: `crates/trusted-server-core/src/integrations/datadome.rs` +- Modify: `crates/trusted-server-core/src/integrations/datadome/protection.rs` +- Modify: `crates/trusted-server-core/src/integrations/datadome/protection_scope.rs` +- Modify: `crates/trusted-server-core/src/integrations/didomi.rs` +- Modify: `crates/trusted-server-core/src/integrations/google_tag_manager.rs` +- Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics.rs` +- Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js` +- Modify: `crates/trusted-server-core/src/integrations/lockr.rs` +- Modify: `crates/trusted-server-core/src/integrations/mod.rs` +- Modify: `crates/trusted-server-core/src/integrations/osano.rs` +- Modify: `crates/trusted-server-core/src/integrations/permutive.rs` +- Modify: `crates/trusted-server-core/src/integrations/sourcepoint.rs` +- Modify: `crates/trusted-server-core/src/integrations/testlight.rs` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add a maximal-bundle failing smoke test that loads core followed by every** + server-declared integration in manifest order and asserts one runtime, no unknown + integration id, no duplicate activation, exact reverse-order disposal, and no + leaked timer/listener/wrapper/observer after disposal. Run every module alone + and in the maximal manifest with missing globals, readiness/timeouts, malformed + config/consent/storage, matcher false positives, callback throws, startup + failure, and cross-integration isolation. + +- [ ] **Step 2: Convert every remaining capability into a thin integration module.** Each + `_registerIntegration({id,release,prepare})` call is pure registration; + `prepare(ctx)` is inert and Promise-returning; the returned `activate(ctx)` is + synchronous, registers a disposer before each reversible mutation, and uses at + most one staged `afterCommit` callback for irreversible work. Exercise all + modules through the same manifest-ordered test composition. Preserve existing + feature behavior and integration-owned matchers/configuration; shared helpers + must not broaden matching, reorder startup, stack interception, or retain work + after disposal. Do not change shipped entry-point side effects until Task 19. + +- [ ] **Step 3: Rebuild creative startup around the exact frozen `CreativeBootV1`.** Validate + the complete plain-object shape, defaults, disabled/manifest mismatch, unknown + keys, accessors, prototypes, and literals before preparation. Activation installs + the click guard when `clickGuard` is true and dynamic image/iframe guards when + `renderGuard` is true, with no baseline DOM rewrite. A still-loading document + receives one owned `DOMContentLoaded` rescan; an already-ready document stages + that scan in the module's single `afterCommit`. Disabled creative and + enabled-with-both-guards-false perform zero wrappers, observers, listeners, + scans, or DOM mutation. Disposal compare-restores only the exact installed + wrapper and clears owned DOM state once. + + Preserve sanitization as opt-in/default-off and rewriting as its independent + existing policy across direct, SSAT, cache, and auction paths. Cover dynamic-node + guards, sandbox attributes, font/CORS/body/base behavior, opaque-origin click + recovery through `/first-party/proxy-rebuild`, validated absolute HTTP(S), and + rejection of credentials, malformed values, and non-network schemes. Delete the + mutable/install creative globals only in Task 22. + +- [ ] **Step 4: Move render tracing to the kernel diagnostics bus and exact public surface.** + `tsjs.diagnostics.renderTrace` exposes only frozen `current()`, `history()`, and + `subscribe()`. Keep current state keyed by exact slot and capped by the 256-slot + navigation registry; prune on disposal. Keep document-runtime history at 200, + one row per physical impression, monotonic `count`/global `seq`, immutable `at`, + and non-weakening enrichment. Remove stale DOM stamp fields/badges on update and + preserve bounded overlay/export failure isolation. + + Commit correctness state before public delivery. Capture subscriber ids and enqueue + frozen full records asynchronously in a 200-entry FIFO keyed by `seq`; same-sequence + enrichment replaces the pending record and captured ids without reordering. One + owned zero-delay task drains FIFO. Enforce callable-before-capacity validation, + 32-live-subscriber cap, idempotent unsubscribe, unsubscribe-before-delivery, + registration-during-dispatch, callback throw isolation, and 199/200/201 overflow. + Emit no `CustomEvent`, mutable trace global, or compatibility alias. + +- [ ] **Step 5: Preserve GPT diagnostics through the adapter event stream.** Validate exact + `DiagnosticsBootV1` plus manifest activation before any listener/buffer exists. + When active, core owns the six documented GPT observations before TS requests, + buffers 512 raw facts until module activation, then replays and releases the + buffer. When inactive, require zero diagnostics-added listeners, DOM, timers, + observers, API, storage, or network work beyond the two correctness listeners. + Preserve exact physical-slot binding/replacement, per-slot monotonic request + numbers, callback truth/timing, frozen exports, Shadow DOM overlay, badges, SPA, + privacy, and non-interference. + + Bound the store to 64 slot objects, ten cycles per slot, and 128 callback issues. + Expose only `tsjs.diagnostics.gpt`, with `snapshot()` plus the shared 32-subscriber + limit. Public delivery uses a separate one-entry latest-snapshot notifier on one + owned zero-delay task; 0/1/2-update coalescing, captured ids, unsubscribe/disposal, + slow/throwing listeners, and callback-stack isolation are executable tests. No + storage, upload, old flag, runtime expando, or `tsjs.gptDiagnostics` alias remains + after Task 22. + +- [ ] **Step 6: Preserve each remaining `rc/july` integration corpus exactly.** Cover DataDome + script/preload path rewriting; Didomi absolute SDK path without config clobber; + GTM script/preload and GA beacon/fetch rewriting; Lockr bounded readiness and API + host; Osano USP/GPP/TCF marker ownership and lifecycle; Permutive bounded + readiness/API host and at-most-100 normalized segments; Sourcepoint optional SDK + plus GPP storage/marker lifecycle; and Testlight preexisting/later callbacks, + invalid entries, and throw isolation. Run unchanged pre-cutover fixtures beside + module-composed fixtures. Permutive and any other auction-context contributor + register only through the injected runtime service during activation and remove + that contribution through the pre-registered disposer; no import-time global + provider survives failed activation or module/runtime disposal, and SPA + navigation does not register a duplicate. + +- [ ] **Step 7: Generate and test the prospective manifest member list/order from the exact** + enabled bundle list. Embed the same release id in core and every integration + IIFE. Add failures for integration before core, unknown/missing/duplicate member, + malformed/unsorted/oversized manifest, wrong release, preparation or activation + failure, duplicate `afterCommit`, and the 16-member/10-second transaction limits. + Production manifest emission starts only in Task 19. + +- [ ] **Step 8: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test + npm --prefix crates/trusted-server-js/lib run build + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run typecheck + cargo test-fastly publisher + ``` + +### Task 19: Complete lifecycle behavior and perform the coordinated production switch + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/services/render.ts` +- Modify: `crates/trusted-server-js/lib/src/services/slots.ts` +- Modify: `crates/trusted-server-js/lib/src/services/projections.ts` +- Modify: `crates/trusted-server-js/lib/src/services/targeting.ts` +- Modify: `crates/trusted-server-js/lib/src/services/reservations.ts` +- Modify: `crates/trusted-server-js/lib/src/services/auction_batch.ts` +- Modify: `crates/trusted-server-js/lib/src/services/context.ts` +- Modify: `crates/trusted-server-js/lib/src/kernel/integration_registry.ts` +- Modify: `crates/trusted-server-js/lib/src/kernel/runtime.ts` +- Modify: `crates/trusted-server-js/lib/src/kernel/sessions.ts` +- Modify: `crates/trusted-server-js/lib/src/adapters/googletag.ts` +- Modify: `crates/trusted-server-js/lib/src/adapters/prebid.ts` +- Modify: `crates/trusted-server-js/lib/src/adapters/messaging.ts` +- Modify: `crates/trusted-server-js/lib/src/core/config.ts` +- Modify: `crates/trusted-server-js/lib/src/core/global.d.ts` +- Modify: `crates/trusted-server-js/lib/src/core/log.ts` +- Modify: `crates/trusted-server-js/lib/src/core/queue.ts` +- Modify: `crates/trusted-server-js/lib/src/core/registry.ts` +- Modify: `crates/trusted-server-js/lib/src/core/trace.ts` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Modify: `crates/trusted-server-js/lib/src/core/request.ts` +- Modify: `crates/trusted-server-js/lib/src/core/auction.ts` +- Modify: `crates/trusted-server-js/lib/src/core/index.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/datadome/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/didomi/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/lockr/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/osano/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/permutive/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/testlight/index.ts` +- Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/tsjs.rs` +- Modify: `crates/trusted-server-core/src/auction/endpoints.rs` +- Modify: `crates/trusted-server-core/src/auction/formats.rs` +- Modify: `crates/trusted-server-core/src/integrations/registry.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- Modify: `crates/trusted-server-adapter-axum/src/app.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs` +- Modify: `crates/trusted-server-adapter-spin/src/app.rs` +- Modify: `crates/trusted-server-integration-tests/tests/parity.rs` +- Modify: `crates/trusted-server-core/src/html_processor.rs` +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs` +- Modify: `crates/trusted-server-core/src/integrations/didomi.rs` +- Modify: `crates/trusted-server-core/src/integrations/sourcepoint.rs` +- Modify: `crates/trusted-server-core/src/integrations/gpt.rs` +- Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics.rs` +- Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js` +- Modify: `crates/trusted-server-js/lib/build-prebid-external.mjs` +- Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Modify: `crates/trusted-server-js/lib/test/core/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/core/request.test.ts` +- Modify: `crates/trusted-server-js/lib/test/core/auction.test.ts` +- Modify: `crates/trusted-server-js/lib/test/kernel/runtime.test.ts` +- Modify: `crates/trusted-server-js/lib/test/services/render.test.ts` +- Modify: `crates/trusted-server-js/lib/test/services/slots.test.ts` +- Modify: `crates/trusted-server-js/lib/test/services/projections.test.ts` +- Modify: `crates/trusted-server-js/lib/test/services/targeting.test.ts` +- Modify: `crates/trusted-server-js/lib/test/services/reservations.test.ts` +- Modify: `crates/trusted-server-js/lib/test/services/auction_batch.test.ts` +- Modify: `crates/trusted-server-js/lib/test/services/context.test.ts` +- Modify: `crates/trusted-server-js/lib/test/core/queue.test.ts` +- Modify: `crates/trusted-server-js/lib/test/core/registry.test.ts` +- Modify: `crates/trusted-server-js/lib/test/core/log.test.ts` +- Modify: `crates/trusted-server-js/lib/test/core/trace.test.ts` +- Modify: `crates/trusted-server-js/lib/test/adapters/googletag.test.ts` +- Modify: `crates/trusted-server-js/lib/test/adapters/prebid.test.ts` +- Modify: `crates/trusted-server-js/lib/test/adapters/messaging.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts` + +- [ ] **Step 1: Add failing tests proving fallback begins only after an attributable TS-owned** + empty GAM cycle; the primary child settles before fallback starts; publisher, + ambiguous, quarantined, timeout, and stale cases do not fall back; both child + histories remain immutable; and `SlotOperation` publishes exactly one final + result with `path:'fallback'` when the child runs. + +- [ ] **Step 2: Snapshot render-relevant configuration at attempt creation. Re-check generation** + and existing kill-switch state immediately before the earliest irreversible + action (bridge response, DOM insertion, or an existing non-APS notification). + +- [ ] **Step 3: Preserve existing non-APS `nurl`/`burl` behavior but route it through the attempt** + terminal transition so it initiates once and never blocks. Add an assertion that + APS never synthesizes either URL. + +- [ ] **Step 4: Test already-loaded-page limits honestly: configuration changes reach a page only** + through an existing response path; do not add polling, push, or event ingestion. + +- [ ] **Step 5: Atomically activate the new production surface in one task and one commit:** + - `/auction` emits/parses only the exact decision-set/tagged-source wire, and + initial HTML/page-bids emit only `tsjs.boot.auctionProjection`; + - the immutable initial projection seeds the first `NavigationSession`; every SPA + page-bids response validates and commits only to the replacement session's + internal projection and never mutates recursively frozen `tsjs.boot`; + - projection parsing enforces the exact 256-array/member, identifier, targeting, + currency/CPM, reservation, dimension, and canonical 8 MiB bounds before mutation; + an over-cap projection converts every otherwise winning decision to + `winner_not_renderable`, emits no projected bid, and omits the corresponding + `/auction` TS seatbid; + - the server emits exact frozen `TsjsBootV1`, `CreativeBootV1`, + `DiagnosticsBootV1`, and `BootManifestV1` before core from generated release + metadata, after validating every integration config and manifest relationship; + - core inertly prepares every required integration in manifest order while no + bridge/listener/global mutation is live. Only after all Promises resolve does the + same-task synchronous activation barrier install the capture bridge as its first + reversible core effect, install correctness GPT listeners, and activate modules + in order with monotonic pre/post-call and pre-handoff checks. Failure rolls back + every reversible effect; success commits the complete `TsjsApi`, runs staged + `afterCommit` callbacks in manifest order, and drains the preload queue; + - the preload queue handoff uses the exact real-Array algorithm: capture ingress, + install the fixed installing descriptor, snapshot, forward retained ingress + pushes, install the frozen final actual Array with own immediate `push` and + `length:0`, publish the complete API, run `afterCommit`, then drain snapshot plus + forwarded work exactly once. Native/borrowed mutators and retained references + cannot retain entries or create a second runtime; + - the kernel surface is exactly `TsjsApi` with semantic `version`, exact + `releaseId`, immutable `boot`, real `que`, `addAdUnits`, Promise `requestAds`, + local `log`, diagnostics, `_registerIntegration`, and frozen status-only + `_internal`. Fallback exposes its exact smaller own surface, validates then refuses + `addAdUnits`, settles known slots with the committed fallback reason, drains the + queue once, and creates no runtime/adapters/listeners/timers/DOM work; + - `addAdUnits` transactionally validates and registers programmatic direct-auction + slots against the same combined 256-slot cap, exact identifier/bidder/dimension + grammar, and collision indexes. Omitted-slot `requestAds` snapshots server and + programmatic registrations in ordinal order; later registrations cannot enter an + in-flight snapshot; + - GPT, Prebid, APS, creative, diagnostics, all remaining integrations, Promise + `requestAds`, versioned APS renderer client, and generated bootstrap/fallback + switch together on the shared sessions/services and terminal latches; + - the external publisher artifact switches as independently useful pure Prebid.js + 10.26.0 with its own watchdog and frozen artifact stamp; TS admission, render, + refresh, targeting, and release matching remain only in the separate Prebid + integration module; + - all adapters atomically register only the versioned static renderer and + unversioned live `/integrations/aps/runner.js` proxy; the abandoned + `/integrations/aps/runner/v1.js` and unversioned renderer are local negative + routes; + - every Rust/JS integration config emitter moves its existing values from + scattered `window.__tsjs_*` globals into its exact `tsjs.boot.*` member before + the corresponding integration prepares; no integration loses configuration; + - accepted artifacts, `WinnerContext`, targeting journals, renderer reservations, + GPT physical-object reconciliation, and navigation ownership use the shared + services; and + - render trace and GPT diagnostics commit only after correctness transitions and + expose their exact bounded asynchronous frozen APIs. Creative guards auto-install + from frozen boot configuration and both-false guards have zero DOM side effects. + + Run old-surface and new-surface fixture tests immediately before the switch, then + require the entire suite green after it. Do not add a production selector, dual + manifest, or shape autodetection. The temporarily unused server routes and old + declarations are deleted in Task 22 before release. + +- [ ] **Step 6: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/services test/core test/integrations/gpt + npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid test/integrations/aps test/kernel + npm --prefix crates/trusted-server-js/lib run build + cargo test-fastly + cargo test-axum + cargo test-cloudflare + cargo test-spin + cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity + ``` + +### Phase 4 exit + +- GPT, Prebid, APS, and all integration entry points use one kernel/integration-module + surface. +- The old registries, sentinels, expandos, refresh wrappers, and bridge branches are + gone. +- All Vitest and production-bundle tests pass. + +## Phase 5 — browser conformance, deletion, and release readiness + +### Task 20: Build the hermetic browser race matrix + +**Files:** + +- Modify: `crates/trusted-server-integration-tests/browser/playwright.config.ts` +- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts` +- Create: `crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts` +- Create: `crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts` +- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts` +- Modify: `crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts` +- Modify: `crates/trusted-server-integration-tests/browser/tests/nextjs/navigation.spec.ts` +- Modify: `crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts` +- Modify: `crates/trusted-server-integration-tests/browser/helpers/infra.ts` +- Modify: `crates/trusted-server-integration-tests/browser/helpers/state.ts` +- Modify: `crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js` +- Modify: `crates/trusted-server-integration-tests/browser/fixtures/prebid-universal-creative-1.17.2.js` +- Modify: `scripts/integration-tests-browser.sh` +- Modify: `.github/workflows/integration-tests.yml` + +- [ ] **Step 1: Extend Playwright projects to Chromium, Firefox, and WebKit for the focused APS** + conformance files. Keep the broader existing suite's browser matrix unchanged + unless runtime permits expansion. + + Extend the clean-checkout browser script's `TS_BROWSER_PROJECTS` input so it + installs the selected engines and forwards Playwright arguments with + `npm --prefix ... exec -- playwright`; it must retain the release-WASM, Viceroy + config, Docker image, npm install, and TSJS fixture preparation from Task 0. + +- [ ] **Step 2: Create deterministic local GPT and locally authored fictional APS-runner** + success/failure fixtures; run the vendored exact PUC 1.17.2 artifact for the + creative path. The fictional runner must not copy, transform, derive from, or + archive APS runner bytes and must never be packaged as a production fallback. Do + not replace PUC's `prebidMessenger`, `runDynamicRenderer`, or `h.sendMessage` + behavior and do not mock the kernel/services under test. + +- [ ] **Step 3: Implement every spec §7.2 browser-observable race as grouped tables with exact** + terminal, DOM, targeting, listener, port, timer, and network assertions: + - PUC claim/join: simultaneous duplicate requests; live/tombstoned/native ids; + wrong source/slot, altered id, SafeFrame-shaped nesting, claim before/after + attributable nonempty or empty GAM, navigation/supersession on both sides, and + replay at tombstone expiry; + - capability/channel ownership: ticket/nonce capacity and eighth-draw collision, + zero/one/two transferred ports, registration before/at/after deadline, caller + abort before/after registration/insertion/document acceptance, channel loss, + settlement-post throw, owner watchdog versus late response, and exactly one + `OwnerSettlementV1` plus Promise settlement. The remote owner removes only an + uncommitted iframe at 20 seconds; accepted DOM survives; + - APS/ADM/cache documents: renderer load/error/removal/replacement, runner + acknowledgement/failure/timeout, exact 1/4096 dimensions in Rust/TS/embedded ES5/ + cache/PUC DOM, ADM initial `about:blank` versus intended `srcdoc`, and proof that + only the current intended navigation can accept. Cache expansion uses only + `String(attempt.winnerContext.selectedCpm)` and leaves `${AUCTION_PRICE:B64}` + untouched; + - runtime/bootstrap: prepare reject/abort, activation throw at every checkpoint, + 9,999/10,000/10,001 ms boundaries, duplicate `afterCommit`, 15/16 member capacity, + late continuation after fallback, publisher work during startup, exact same-task + rollback, full/fallback `TsjsApi` own surfaces, malformed boot, actual-Array queue + swap/retained references/native mutators/nested pushes/callback throws, and missing + main bundle after server projection; + - navigation/projection/API: immutable initial boot versus SPA-owned replacement, + stale/duplicate/malformed page-bids, exact grammar/count/UTF-8 and 8 MiB all-winner + reduction, 255/256/257 combined server/programmatic slots, transactional + `addAdUnits`, explicit and omitted `requestAds` snapshots, unknown/colliding ids, + concurrent partially overlapping batches, aborts/timeouts, logger/error behavior, + and attempt-prefix/ordinal exhaustion without issued-id retention; + - GPT/targeting: readiness, request-start, and completion on both deadline sides; + SRA ordering, duplicate response ids, old navigation callbacks, handoff and + disabled-initial-load suppression, DOM reconciliation at 249/250 and + 4,999/5,000 ms, two-success cap, throw/false destroy, no second physical slot, + publisher ownership, and identical-string targeting generations plus same-value/ + different-value set, per-key clear, clear-all, non-top rebase, and artifact + promotion/disposal races; + - Prebid: missing/stub/late/duplicate/older/partial artifacts; exact 10.26.0 and all + stamp/manifest caps; same-release reuse, different-release refusal, hostile own + property shapes, watchdog versus late module, `pbjs`/stamp replacement, exact + prepared-bid admission and non-publication, bidder aliases/user-ID/EID coverage, + selection/loser/timeout/abort behavior, refresh exclusions, native bid/queue + survival, and proof that the external artifact has no TS auction/render behavior; + - creative/diagnostics/integrations: every creative policy/boot/automatic-guard/ + opaque-click case; render-trace ordering/enrichment/200-history/200-pending/32- + subscriber/async-delivery cases with no event alias; GPT diagnostics 512-fact, + 64-slot, 10-cycle, 128-issue, 32-subscriber, latest-snapshot and inactive-zero- + effect cases; and every remaining integration alone/maximal with startup failure, + disposal, matcher, storage/consent, callback, and cross-isolation cases; and + - transport/protocol: every message/body/count/string boundary at minus-one/exact/ + plus-one with multibyte and malformed encodings, plus the complete runner-proxy + redirect, media type, encoding, content-length, streamed-size, slow-drip, header- + stripping, byte-preserving, and empty non-leaking failure corpus through all + actual adapters, including Cloudflare and Spin wasm evidence. + +- [ ] **Step 4: Assert DOM/network/lifecycle outcomes directly. The suite must run with no** + external analytics or persistence service. + +- [ ] **Step 5: Run:** + + ```bash + ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum + ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly + ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare + ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin + TS_BROWSER_FRAMEWORKS=nextjs \ + TS_BROWSER_PROJECTS=chromium,firefox,webkit \ + ./scripts/integration-tests-browser.sh \ + tests/shared/aps-renderer.spec.ts \ + tests/shared/aps-puc-lifecycle.spec.ts \ + tests/shared/tsjs-runtime.spec.ts \ + --project=chromium --project=firefox --project=webkit + ``` + +### Task 21: Add and pass the attested real-GAM test-network suite + +**Files:** + +- Create: `crates/trusted-server-integration-tests/browser/tests/shared/aps-real-gam.spec.ts` +- Create: `crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts` +- Create: `crates/trusted-server-integration-tests/browser/playwright.real-gam.config.ts` +- Create: `crates/trusted-server-integration-tests/fixtures/configs/aps-real-gam.template.toml` +- Create: `.github/workflows/aps-real-gam.yml` +- Do not create a second runbook; this plan contains the release gate and commands + +- [ ] **Step 1: Add a manually dispatched `aps-real-gam.yml` job using the protected GitHub** + environment `aps-real-gam` and a required `workflow_dispatch` string input + `release_id`, plus required `evidence_id` and `previous_artifact_id` inputs used + only for attestation/rollback evidence. Its exact environment contract is + `TS_REAL_GAM_PAGE_URL`, `TS_REAL_GAM_AUTH_HEADER`, and + `TS_REAL_GAM_EXPECTED_RELEASE_ID`; the first two are protected secrets and no + value is checked in. The template contains fictional placeholders only. This job + is not added to ordinary PR CI, but a successful run for the exact release id is + a mandatory cutover artifact. + + The dedicated Playwright config has no local `globalSetup`, `globalTeardown`, + Viceroy, Docker, or WASM dependency. It runs only the remote real-GAM spec against + `TS_REAL_GAM_PAGE_URL`; importing the shared local config is forbidden by a test. + +- [ ] **Step 2: Cover SSAT APS-PUC, Trusted Server Prebid APS-PUC, page-bids APS-PUC, direct APS,** + direct ADM/cache, attributable empty fallback, SRA, refresh, SPA navigation, and + collapsed-shell resize. + +- [ ] **Step 3: Add negative fixtures for wrong id/source, invalid descriptor, no outer claim,** + no owner registration, no document acknowledgement, and APS runner failure. + Exercise the live fixed-target proxy and assert route, DOM, nested-iframe, exact + lifecycle callback, and terminal outcomes. There is no runner digest/version + check: mutable APS bytes are not TS source or release identity. + +- [ ] **Step 4: Capture browser console, GPT events, sanitized network metadata, DOM snapshots,** + screenshots, and sanitized traces under `test-results/`, `playwright-report/`, + and `real-gam-evidence/`. Disable response-body/HAR embedding for the APS runner + and creative resources and strip any such bodies if the browser tool records them + despite configuration. Before upload, inspect every archive/trace/HAR and fail if + it contains a runner or creative response body, authorization value, account id, + descriptor, or lifecycle capability; metadata such as URL/status/timing is + allowed. Upload one artifact named + `aps-real-gam-` with the actual GitHub run id and 30-day retention. + Pass/fail comes from + exact request/DOM/lifecycle assertions. + +- [ ] **Step 5: Require a clean pass in Chromium, Firefox, and WebKit before cutover. If the GAM** + test network itself cannot support a browser, record that as a release blocker + instead of silently weakening the criterion. + +- [ ] **Step 6: Provide and verify the exact manual equivalent from the browser package:** + + ```bash + TS_REAL_GAM_PAGE_URL=... \ + TS_REAL_GAM_AUTH_HEADER=... \ + TS_REAL_GAM_EXPECTED_RELEASE_ID=... \ + npm --prefix crates/trusted-server-integration-tests/browser exec -- \ + playwright test --config=playwright.real-gam.config.ts \ + tests/shared/aps-real-gam.spec.ts \ + --project=chromium --project=firefox --project=webkit + ``` + +### Task 22: Delete final legacy surfaces and enforce absence + +**Files:** + +- Modify: `crates/trusted-server-js/lib/package.json` +- Modify: `crates/trusted-server-js/lib/eslint.config.js` +- Create: `crates/trusted-server-js/lib/scripts/check-architecture.mjs` +- Modify: `crates/trusted-server-js/lib/src/core/global.d.ts` +- Modify: `crates/trusted-server-js/lib/src/core/index.ts` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Delete: `crates/trusted-server-js/lib/src/core/context.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/index.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/globals.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/didomi/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts` +- Modify: `crates/trusted-server-js/lib/build-prebid-external.mjs` +- Modify: `crates/trusted-server-js/lib/test/core/request.test.ts` +- Delete: `crates/trusted-server-js/lib/test/core/context.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/creative/helpers.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs` +- Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` +- Modify: `crates/trusted-server-core/src/html_processor.rs` +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs` +- Modify: `crates/trusted-server-core/src/integrations/didomi.rs` +- Modify: `crates/trusted-server-core/src/integrations/sourcepoint.rs` +- Modify: `crates/trusted-server-core/src/integrations/gpt.rs` +- Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics.rs` +- Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js` +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Modify: `crates/trusted-server-core/src/auth.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- Modify: `crates/trusted-server-adapter-axum/src/app.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs` +- Modify: `crates/trusted-server-adapter-spin/src/app.rs` +- Modify: `crates/trusted-server-adapter-axum/tests/routes.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/tests/routes.rs` +- Modify: `crates/trusted-server-adapter-spin/tests/routes.rs` +- Modify: `crates/trusted-server-integration-tests/tests/parity.rs` +- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts` +- Modify: `docs/guide/integrations/aps.md` +- Modify: `docs/guide/auction-orchestration.md` +- Modify: `docs/guide/configuration.md` +- Modify: `docs/guide/creative-processing.md` +- Modify: `docs/guide/integration-guide.md` +- Modify: `docs/guide/integrations/prebid.md` +- Modify: `docs/guide/integrations/didomi.md` + +- [ ] **Step 1: Add an absence test/search for:** + - `globalThis.tscreative`, `globalThis.tsCreativeConfig`, `installGuards`, + `setConfig`, and `getConfig`; + - legacy `window.__tsjs_*` runtime flags; + - callback/void `requestAds`, `renderAdUnit`, `renderAllAdUnits`, mutable generic + config, `TsjsApiV1`, and every old public declaration/alias; + - `tsjs.renders`, `renderLog`, `renderSeq`, `tsjs:adRendered`, + `tsjs.gptDiagnostics`, and old GPT diagnostics flags/expandos; + - `__tsRenderGeneration` and `__tsRenderBid`; + - `tsjs.apsPrebidRenderers`; + - the module-global core context-provider map and integration imports of + `registerContextProvider`/`collectContext`; + - integration-owned GPT/Prebid function sentinels; + - duplicate `Prebid Request` listeners and refresh wrappers; + - empty catches in migrated paths; + - `PAGE_BIDS_LEGACY_PATH`, `/__ts/page-bids`, and the JS retry/fallback marker; + - the unversioned `/integrations/aps/renderer` route; + - APS `pub_id` deserialization alias and its compatibility documentation; + - any APS runner asset, copied body, version/digest/metadata/license record, + updater/downloader, SRI/integrity attribute, generated runner artifact, offline + fallback, positive `/integrations/aps/runner/v1.js` route, or runner-cache + requirement; + - any enabled `TS_TEST_APS_V1`, integration-only upstream resolver, loopback + fixture address, Wrangler service binding, or Spin/Viceroy proxy-test manifest + reference in a production build/release artifact; + - every temporary architectural lint allowlist entry. + + For `window.__tsjs_*`, assert absence in all shipped JavaScript, including the pure + generated Prebid external artifact. For every former integration configuration + emitter/consumer, separately assert the exact immutable `tsjs.boot.*` replacement + in server output, integration consumers, fixtures, and current guides; deleting an + emitter without migrating its value is a test failure. + + Scope the executable search to shipped source, current guides, tests, scripts, + and workflows; exclude historical `docs/superpowers` designs/plans because this + work does not rewrite separate completed specifications. The enumerated file list + above is the current baseline hit inventory and must be updated if Task 0 finds + another in-scope hit. + +- [ ] **Step 2: Delete unreachable old paths, compatibility declarations,** + branches, and test-only production exports. Keep only `tsjs.que`, `tsjs.boot`, + the exact `TsjsApi` public surfaces, `_registerIntegration`, and the frozen + status-only `_internal` surface described by the spec. Do not expose the + service registry or a second integration-registration name. + +- [ ] **Step 3: Make `/__ts/page-bids`, the unversioned renderer path, unknown renderer versions,** + and `/integrations/aps/runner/v1.js` local unknown-route responses, never aliases. + Keep only `/_ts/page-bids`, static `/integrations/aps/renderer/v1`, and live + fixed-target proxy `/integrations/aps/runner.js`. Update route/parity tests and + APS/configuration guides to those hard-cutover surfaces. The absence test must + distinguish the required negative `runner/v1.js` assertion from a forbidden + positive handler. + +- [ ] **Step 4: Add an executable absence script to `package.json` and CI, build every** + integration combination used by server fixtures, and rerun all TS and adapter + route tests. + +### Task 23: Add deterministic bundle, browser-time, and retained-heap gates + +**Files:** + +- Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Modify: `crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs` +- Read: `crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json` +- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts` +- Modify: `.github/workflows/test.yml` +- Modify: `.github/workflows/integration-tests.yml` + +- [ ] **Step 1: Consume, but do not regenerate, the pre-change artifact captured in Task 0. Fail** + minimal/reference/maximal deterministic gzip or Brotli growth above 5% unless a + separate review explicitly updates the baseline. + +- [ ] **Step 2: On the pinned Chromium/CI-machine/fixture, measure boot-to-first-display p90 after** + five warmups and 50 samples and require ≤1.10× the Task 0 baseline. Do not rerun + selectively to turn a failed sample into a pass. + +- [ ] **Step 3: Through Chromium CDP, collect garbage then record retained heap after boot, first** + render, refresh, and SPA navigation; gate each checkpoint at ≤1.10×. Firefox and + WebKit remain correctness-only and do not emit synthetic heap equivalents. + +- [ ] **Step 4: Keep these gates separate from render correctness: performance cannot convert a** + failed conformance test to a pass. + +- [ ] **Step 5: Run the gate from a clean checkout through the same fixture-preparation script** + and write a separate measured artifact; never overwrite the Task 0 baseline: + + ```bash + TS_BROWSER_FRAMEWORKS=nextjs \ + TS_BROWSER_PROJECTS=chromium \ + TSJS_PERF_MODE=gate \ + TSJS_PERF_OUTPUT=crates/trusted-server-integration-tests/browser/test-results/tsjs-performance-current.json \ + ./scripts/integration-tests-browser.sh \ + tests/shared/tsjs-performance.spec.ts --project=chromium + node crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs + ``` + +### Task 24: Run final repository verification and assemble the cutover evidence + +**Files:** + +- Modify: `.github/workflows/test.yml` +- Modify: `.github/workflows/integration-tests.yml` +- Modify: `.github/workflows/aps-real-gam.yml` +- Do not create another plan/design/runbook; workflow artifacts are the evidence + +- [ ] **Step 1: Run formatting:** + + ```bash + cargo fmt --all -- --check + npm --prefix crates/trusted-server-js/lib run format + npm --prefix docs run format + ``` + +- [ ] **Step 2: Run Rust correctness and lint for every adapter:** + + ```bash + cargo test-fastly + cargo test-axum + cargo test-cloudflare + cargo test-spin + cargo clippy-fastly + cargo clippy-axum + cargo clippy-cloudflare + cargo clippy-cloudflare-wasm + cargo clippy-spin-native + cargo clippy-spin-wasm + cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity + ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum + ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly + ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare + ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin + cargo clippy --manifest-path crates/trusted-server-integration-tests/Cargo.toml --all-targets -- -D warnings + ``` + +- [ ] **Step 3: Run TypeScript and bundle verification:** + + ```bash + npm --prefix crates/trusted-server-js/lib ci + npm --prefix crates/trusted-server-js/lib run typecheck + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib test + npm --prefix crates/trusted-server-js/lib run build + npm --prefix crates/trusted-server-js/lib run build:prebid-external + npm --prefix crates/trusted-server-js/lib run check:aps-contract + npm --prefix crates/trusted-server-js/lib run check:architecture + node --test crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs + node crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs + npm --prefix docs run lint + npm --prefix docs run build + ``` + +- [ ] **Step 4: Run three clean-checkout workflows for the exact release commit. Add** + `workflow_dispatch` with required `evidence_id` and `release_id` inputs to + `test.yml`; that workflow executes the complete format/typecheck/lint/Vitest/ + bundle/Rust-test/clippy matrix from steps 1–3 and uploads its command logs plus + validated release id. The integration workflow owns adapter startup/artifact + setup and executes the full integration suite plus focused + Chromium/Firefox/WebKit APS files. The protected real-GAM workflow runs for the + same ref/release id. + + Add manual `evidence_id`, `release_id`, and `previous_artifact_id` inputs where + relevant and include the evidence id in each workflow's `run-name`. After the + checked build, derive `RELEASE_ID` only from Task 8's validated generated + manifest. For the repository's Fastly production target, the authoritative prior + immutable artifact is the one active Fastly service version immediately before + dispatch; query it through the pinned Fastly CLI and require exactly one active + version. Pass both values explicitly; an empty/ambiguous value is a blocker. + Dispatch only a pushed branch verified to resolve to `RELEASE_SHA`, capture each + exact run id through the Task 0 dispatch helper, wait with `--exit-status`, and + verify every `headSha`, conclusion, and release-id attestation: + + ```bash + RELEASE_REF="$(git branch --show-current)" + RELEASE_SHA="$(git rev-parse HEAD)" + RELEASE_ID="$(npm --prefix crates/trusted-server-js/lib run --silent print:release-id)" + test -n "$RELEASE_REF" + test -n "$RELEASE_ID" + test -n "$FASTLY_SERVICE_ID" + PREVIOUS_FASTLY_VERSION="$(fastly service version list \ + --service-id "$FASTLY_SERVICE_ID" --json | \ + jq -er '[.[] | select(.active == true)] | if length == 1 then .[0].number else error("expected one active Fastly version") end')" + PREVIOUS_ARTIFACT_ID="fastly-service-version:$PREVIOUS_FASTLY_VERSION" + git fetch origin "$RELEASE_REF" + test "$RELEASE_SHA" = "$(git rev-parse "origin/$RELEASE_REF")" + test -n "$PREVIOUS_ARTIFACT_ID" + EVIDENCE_ID="aps-tsjs-cutover-$RELEASE_SHA" + QUALITY_RUN_ID="$(node scripts/dispatch-workflow-run.mjs \ + test.yml "$RELEASE_REF" \ + evidence_id="$EVIDENCE_ID" \ + release_id="$RELEASE_ID")" + INTEGRATION_RUN_ID="$(node scripts/dispatch-workflow-run.mjs \ + integration-tests.yml "$RELEASE_REF" \ + evidence_id="$EVIDENCE_ID" \ + release_id="$RELEASE_ID" \ + previous_artifact_id="$PREVIOUS_ARTIFACT_ID")" + REAL_GAM_RUN_ID="$(node scripts/dispatch-workflow-run.mjs \ + aps-real-gam.yml "$RELEASE_REF" \ + evidence_id="$EVIDENCE_ID" \ + release_id="$RELEASE_ID" \ + previous_artifact_id="$PREVIOUS_ARTIFACT_ID")" + for RUN_ID in "$QUALITY_RUN_ID" "$INTEGRATION_RUN_ID" "$REAL_GAM_RUN_ID"; do + gh run watch "$RUN_ID" --exit-status + test "$RELEASE_SHA" = "$(gh run view "$RUN_ID" --json headSha --jq .headSha)" + test success = "$(gh run view "$RUN_ID" --json conclusion --jq .conclusion)" + done + ``` + + All three exact runs must conclude `success`; every evidence manifest must report + the same embedded release id and commit SHA, and integration plus real-GAM + artifacts must report the same prior artifact id. The dispatch helper or a + post-download manifest check verifies those fields against `RELEASE_ID`, + `RELEASE_SHA`, and `PREVIOUS_ARTIFACT_ID`; a run-name alone is not evidence. An + unavailable protected environment is a blocker. + +- [ ] **Step 5: Audit the final diff:** + - only planned source/test/build/runbook surfaces changed; + - no old/new compatibility path remains; + - no new external observability, persistence, billing, or experiment artifact; + - no descriptor/capability/account/creative payload is logged; + - no empty catch or unowned timer/listener/port/iframe in migrated paths; + - every one of the 144 pinned `rc/july` files maps through all 38 live rows to the + same 23 ledger ids, with no unmapped/dead/gap result; + - no integration-module preparation performs observable work, no activation yields, + and no post-fallback callback can revive the kernel; + - the public surface is `TsjsApi` only; numeric suffixes remain only on serialized + versioned boot/wire/artifact schemas; and + - no unrelated integration behavior was refactored. + +- [ ] **Step 6: Extend the workflows to upload `aps-tsjs-quality-`,** + `aps-tsjs-cutover-`, and `aps-real-gam-` artifacts, + substituting actual GitHub values. Include exact command logs, sanitized + Playwright reports/traces, route parity output, corpus/staleness output, bundle/ + performance reports, release id, commit SHA, run id, conclusion, and where + applicable the prior deployable artifact id. Run the Task 21 pre-upload scrub on + every browser artifact and fail if APS runner/creative bodies, secrets, + descriptors, or capabilities are present. GitHub Actions artifacts for those + three successful runs are the sole evidence location; do not create another + repository document. + +## Cutover procedure + +Use the existing deployment mechanism; this plan adds no router or experiment +infrastructure. + +1. Deploy to pre-production and rerun renderer-route, direct, PUC, refresh, SRA, and + SPA smoke tests. +2. Confirm the final artifact contains only the new runtime/API and that server and + TSJS bundles belong to the same ordinary release. +3. Hold an exclusive production deployment window from Task 24 evidence capture + through cutover. Immediately before deployment, re-query the active Fastly + version with the same exact command and require it to equal the attested + `PREVIOUS_FASTLY_VERSION`: + + ```bash + CURRENT_FASTLY_VERSION="$(fastly service version list \ + --service-id "$FASTLY_SERVICE_ID" --json | \ + jq -er '[.[] | select(.active == true)] | if length == 1 then .[0].number else error("expected one active Fastly version") end')" + test "$CURRENT_FASTLY_VERSION" = "$PREVIOUS_FASTLY_VERSION" + ``` + + Any mismatch blocks deployment and requires fresh evidence for the new prior + artifact; never roll back to a stale attestation. + +4. Perform one binary production cutover. Immediately verify: + - existing service availability/error/latency health is normal; + - APS renderer endpoint smoke passes; + - direct APS and one GAM/PUC APS smoke pass; + - no CSP/security console regression; + - no non-APS cache/ADM, native Prebid, refresh, SRA, or SPA regression. + +5. Hold before cutover if evidence is missing. After cutover, roll back the complete + artifact immediately on a TS code, request, CSP/security, or non-APS regression; + do not add percentage routing or dual-pool infrastructure. For Fastly, rollback + reactivates the exact attested prior immutable version: + + ```bash + fastly service version activate \ + --service-id "$FASTLY_SERVICE_ID" \ + --version "$PREVIOUS_FASTLY_VERSION" + ``` + +6. Treat a live-runner incident separately because binary rollback cannot restore + older APS-owned bytes. If the proxied runner is unavailable, incompatible, or + produces suspect completion behavior, disable `[integrations.aps]` using the + existing configuration mechanism. Verify that new APS bids are not admitted and + both reserved APS routes return local `404 no-store` without publisher fallback. + Keep APS disabled until the controlled Chromium/Firefox/WebKit real-browser + conformance gate passes again; do not vendor or pin a runner as containment. +7. Monitor existing operational signals for 24 hours and rerun the focused + real-browser smoke suite. +8. Confirm again that the deployed artifact contains no development selector or + compatibility path; Task 22 made this a pre-release absence gate. + +## Completion criteria + +The plan is complete only when: + +1. All five render flows pass exact hermetic and real-GAM lifecycle assertions, and + every created attempt settles exactly once under the mandatory race matrix. +2. Rust, TypeScript, embedded ES5, programmatic registration, cache expansion, and + DOM validation agree on the exact descriptor grammar and 1–4096 dimensions while + preserving invalid-versus-out-of-range reasons. +3. Initial and SPA projections enforce all grammar/count/UTF-8/8 MiB bounds + transactionally; SPA state lives only in `NavigationSession`, boot stays frozen, + and over-cap projections reduce all winners to `winner_not_renderable` without TS + projected bids or `/auction` seatbids. +4. Server reservations are the sole PUC authority, attempt/ticket/nonce issuers are + bounded as specified, and every live reservation/attempt retains the immutable + `WinnerContext` used for cache price expansion instead of response, targeting, or + current-projection data. +5. GPT physical-cycle ownership, exact handoff, two-success DOM reconciliation, + transactional destroy/redefine, and the owner-and-value targeting journal pass all + publisher-mutation and same-string generation races without a second physical + slot or blind clear. +6. PUC capture, owner-control, direct/remote iframe cleanup, accepted-artifact + promotion, and the 20-second owner watchdog obey their exact ownership boundaries; + native Prebid requests remain untouched. +7. One runtime owns all integration modules, sessions, slots, projections, auction- + context contributors, reservations, batches, targeting frames, timers, listeners, + observers, ports, and renderer iframes. Module preparation is inert, activation is + synchronous and reversible, commit is atomic, and post-commit work cannot expose a + partial kernel. +8. The kernel and fallback expose their exact `TsjsApi` own surfaces, semantic + version/release identity, immutable boot, actual-Array queue semantics, logger, + programmatic registration, Promise `requestAds`, and diagnostics presence. No + `TsjsApiV1` or placeholder/callback alias exists. +9. The pure external Prebid.js 10.26.0 artifact independently drains publisher work, + implements the exact frozen artifact stamp and duplicate/conflict rules, binds + `pbjs` plus stamp identity, and contains no TS auction, admission, render, + targeting, refresh, global flag, or TSJS release coupling. +10. Render trace and GPT diagnostics expose only the exact bounded frozen asynchronous + APIs; no correctness callback runs publisher diagnostics code, no legacy event or + alias remains, inactive GPT diagnostics has zero incremental side effects, and + creative guards auto-install from exact frozen boot data with both-false zero DOM + effects. +11. Every one of the 144 pinned `rc/july` files maps through 38 live mappings to all + 23 ledger ids, and the complete GPT, Prebid, APS, creative, diagnostics, shared- + helper, remaining-integration, and browser parity corpora pass alone and in the + maximal manifest. +12. Static renderer and live fixed-target runner-proxy status, exact headers, + bounded/deadline behavior, and fail-closed evidence parsing are proven through + all four actual adapter transports; no APS runner bytes/version/digest/license/ + SRI/updater/fallback/cache requirement exists in TS source or release evidence. +13. Legacy globals, expandos, sentinels, duplicate listeners/wrappers, mutable + creative/diagnostics surfaces, old routes, old declarations, and compatibility + shape detection are absent after the hard cutover. +14. Non-APS cache/ADM, native Prebid, publisher GPT, SRA, refresh, SPA, creative, + notification, and every other integration regression suite passes without an + unrelated feature rewrite. +15. Format, lint, typecheck, adoption/architecture/absence checks, all adapter tests + and clippy targets, Vitest, bundle/artifact builds, Playwright, size, browser-time, + and retained-heap gates pass in attested clean-checkout quality, integration, and + real-GAM runs for the exact release SHA and release id. +16. The binary cutover and 24-hour monitor complete or the exact prior immutable + artifact is restored cleanly; no runtime selector, percentage router, or dual + protocol is introduced. +17. The emergency APS-disable path stops admission and returns local `404 no-store` + for both reserved APS routes; binary rollback is never represented as restoring + mutable upstream runner bytes. +18. No analytics, persistence, billing, experimentation, deployment-routing, or new + external observability requirement was introduced. diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index 78a31b9b7..d4cb142bc 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1,1252 +1,3137 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 10 — closes the ninth review round (assignment-id - trusted path, control-build event parity, affinity lifetime, two-phase - bridge ack, missing-slot termination, materialized cycle denominator, - publisher-bound affinity token, admin route family, billing estimator - source, independent RC attestation) and re-inlines the alternatives and - risk registers so the document is genuinely self-contained. +- **Status:** revision 27 — hard-cutover contract with complete `rc/july` TSJS + adoption - **Date:** 2026-08-04 -- **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed - GAM shells"). All file:line citations refer to this commit. -- **Inputs:** three code audits; design reviews of revisions 1–8; open issues - #926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997. -- **Normative gates:** Appendix A ships with this design; changes require - reviewed decision records. -- **Adoption stance for the baseline APS fixes (`248fe9558`):** contracts - adopted (MessageChannel handshake semantics, collapsed-shell remediation, - consolidated bridge branch), implementations rebuilt inside the target - architecture; the baseline browser tests pass unmodified as the - conformance pin. - -## 0. Release policy: coordinated hard cutover - -- One release: server, TSJS bundles, config, HTML under one **`release_id`**. - No N/N−1; in-flight clients may fail at cutover — accepted and stated. -- **Exact release matching**; mismatch is a refusal. -- **Config is a release-time, content-verified input.** `format_version` - exact-match. **`config_hash` = SHA-256 over the exact fetched envelope - bytes**, bound through compiled release metadata (or a signed manifest - with an embedded verification key); the binary verifies at startup and - fails loudly on mismatch. Publish order: blob, then manifest. Rollback = - redeploy the previous release with its own verified config; binding - prevalidated. -- Assets: embedded only; hashed pathnames for cache identity; unknown hash → - `410`, `no-store`. -- **Authenticated sticky affinity.** The router sets `ts-rel` on HTML - responses — format - `r1.......`: - `kid ^[a-z0-9-]{1,16}$`; canonical decimal `exp`; `phost` = normalized - publisher host; `release ^[a-z0-9._-]{1,40}$` from the allowlist; - `cohort ∈ {canary, control}`; `assignment_id` = 32-hex CSPRNG (the - pseudonymous **randomization unit**, §8); `sig` = unpadded base64url - HMAC-SHA-256 over the domain-separated length-prefixed input - `"ts-affinity-v1" || u32be(len)||field …` over **every** field - including `phost` (so a valid token replayed against another publisher - host fails); keys owned/rotated by the routing layer (active + previous, - retained ≥ the affinity lifetime + skew); constant-time verification; - test vectors checked in. Attributes `Secure; HttpOnly; SameSite=Lax; -Path=/`. Cache keys use the post-validation release label only. -- **Affinity lifetime covers the experiment.** `exp` TTL is set to the - **maximum experiment window** in force (default 14 days — the billing - gate's 7 d + 7 d extension), not 24 h, so a canary visitor never crosses - over to control mid-experiment. Renewal on any HTML response before - expiry re-signs the **same `assignment_id` and `cohort`** with a fresh - `exp` (a browser session's arm is fixed for the experiment). Expired - tokens are distinguished from forged ones (expired = valid sig, past - `exp`) and, during an active experiment, expired-but-valid tokens are - renewed to their original arm rather than reassigned. -- **assignment_id reaches telemetry only by a trusted server path.** The - cookie is `HttpOnly`; the client never reads it and never sends - `assignment_id`. The router **strips any inbound assignment/cohort - header**, validates `ts-rel`, and injects trusted internal metadata - (`X-TS-Cohort`, `X-TS-Assignment`, `X-TS-Release`) on the proxied - request; the client-events handler stamps those onto every row - server-side. Ingest rejects any client-supplied assignment/cohort field. - Tests: spoofed inbound header stripped; wrong-arm; null-rate; cookie - expiry mid-session; cross-pool. -- **State-dependent routing defaults:** during canary, valid tokens route by - their binding; invalid/forged → control + reissue; expired-but-valid → - renewed to their arm (above). **Forward cutover ("weight 100%") - explicitly retires the old release:** for HTML **and** non-HTML request - families, stale/invalid/old-release-bound tokens are reassigned/routed to - the active release — 100% means 100%. **Rollback symmetrically retires - the new release:** still-valid canary bindings are overridden on every - request family, not merely defaulted. -- **CSP-report affinity never depends on cookies:** the renderer is - sandboxed without `allow-same-origin` (`aps/render.ts:4`), so its - browser-generated reports are cross-origin to the publisher endpoint and - carry no cookie. Release/cohort identity is encoded in the - **server-generated report path's `policy_id`** (minted per - `{release, policy version, cohort}`, registered in the header manifest); - the router routes `/_ts/csp-reports/` by that registry. -- Beacon and trace-auth transports use `credentials: "same-origin"` so the - affinity cookie routes them; handlers derive no identity from cookies. -- **Canary/control measurement (closing the empty-control-arm gap):** the - control pool runs an **observation-only control build** — baseline render - _decisions_, but with **every measurement-only lifecycle hook the - Phase-3 gates read**. The control build implements a normative - **event-parity contract**: it emits `request_cycle_started`, - `attempt_started`, `bridge_request`, `bridge_response_sent`, - `renderer_document_loaded`/`adm_document_loaded`, and `render_terminal` - at the **same timing points** as the canary build, populated from - baseline behavior (e.g. its own `slotRenderEnded`, its own bridge - responses) — only the render-decision code differs, never the event - definitions or their emission points. Both arms therefore populate the - funnel and latency gates identically. Arms write to **arm-specific - datasources**; the union view stamps a trusted `deployment_pool` from the - write identity. The event-parity contract is itself gated by an **A/A - test** (canary-build vs canary-build) that must show zero metric - movement before any A/B canary begins. -- Router weight over sticky cohorts is the sole activation primitive; flags - are in-pool emergency kill switches. Cutover = weight 100% + CDN purge; - rollback = weight back + re-purge. The affinity acceptance test covers - HTML, assets, APIs, beacons, and CSP reports (via path identity). - -## 1. Problem statement - -APS demand is fully integrated server-side, yet APS creatives do not appear -reliably. Four serial fixes (the `bid.meta` carrier, the decoupled shim, -the `hb_adid` fallback, the baseline PUC/collapsed-shell fix) each survived -review; the pattern is the finding: **multiple independent failure points, -most failing silently**, with no client→server signal about which fired. -The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths, -duplicated ES5/TS logic, inverted layering, ~100 error-swallowing catches) -is the same problem structurally. - -### Non-goals - -- No change to the APS OpenRTB endpoint contract (including its deliberate - absence of `nurl`/`burl`, §G4d). -- No rewrite of the decoupled Prebid.js strategy. -- No backward compatibility (§0); replacement surfaces in §7.4. - -## 2. Why APS does not render — evidence - -Flows: (a) SSAT via `window.tsjs.bids`; (b) GAM + client `trustedServer` -Prebid adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction`. Only (d) -renders an APS descriptor without GAM. - -### 2.1 Admission - -| # | Failure | Where | -| --- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | -| A1 | A configured `[auction].mediator` discards every direct-provider bid; APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` | -| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; counted but invisible (A4). | `aps.rs:161`, `:334`, `:793` | -| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:675`, `:763-796`, `:859` | -| A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and `ts-debug` exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | - -### 2.2 Identity - -| # | Failure | Where | -| --- | --------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| B1 | GAM caps key-values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge match, no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1695` | -| B2 | Two id universes: SSAT keys on the APS bid id; the client adapter on Prebid's `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` | - -### 2.3 Render - -| # | Failure | Where | -| --- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` reachable only from flow (d). | `gpt/index.ts:923-1180`, `core/request.ts:59` | -| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1247`, `aps/render.ts:30`, `:415-437` | -| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` | -| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` | -| C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated. | `gpt/index.ts:1729` | -| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` | -| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` | - -### 2.4 Observability - -Zero client→server reporting; a bid that never painted is byte-identical -server-side to one that painted. - -### 2.5 Failure → signal mapping (normative) - -The **tester cookie** (non-security, `tester_cookie.rs:3`) gates **debug -content** (`tsjs.boot.debug`, response `ext.trusted_server.debug` — same -class as the existing `ts-debug` comment). The **diagnostic credential** -(§5.3) gates **telemetry volume**. Neither crosses into the other's role. - -| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface | -| ------- | ---------------------------------------------------- | ------------------------------------- | ------------------------------- | -| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary | -| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary | -| A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary | -| A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` | -| B1/B2 | `bridge_request{matched:false}` (§6.8) | join via trace | console warn | -| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn | -| C2 | `render_terminal{failed, renderer_document_no_load}` | `ts_ops_counters` | console warn | -| C3 | `render_terminal{failed, bridge_id_mismatch}` | join via trace | console warn | -| C4 | `render_terminal{failed, descriptor_invalid}` | schema corpus CI | console warn | -| C5 | — (fixed at baseline) | — | — | -| C6 | `runner_failed` + CSP buckets | `ts_csp_reports` | console warn | -| C7 | full §5.1 sequence from renderer branches | join via trace | debug/warn | - -## 3. The GPT and baseline reality - -1. Bootstrap-first hybrid; the bundle's handoff/initial-load code is dead - in production. -2. #922 merge loss (`0dc9b19a9`); PR #997 is the apparent replacement. -3. TS refreshes never pass `changeCorrelator: false`. -4. `enableSingleRequest()` called blind after publisher `enableServices()`. -5. Responsive-resolution ambiguity silently skips slots. -6. Three independent `pubads().refresh` wrappers. -7. GPT has no cancellation, no per-refresh identity, no completion-order - guarantee; `slotRenderEnded` = code injected; `responseIdentifier` - identifies responses only. -8. `display()` under disabled initial load creates no request — for any - caller (`gpt/index.ts:1175`, `ad_init.test.ts:1201-1263`). -9. `slotRenderEnded` registration gated behind `!ts.servicesEnabled` - (`gpt/index.ts:1091`). -10. Baseline `248fe9558`: MessageChannel APS-PUC handshake - (`aps.rs:65-125`, `aps/render.ts:415-437`), collapsed-shell resize - (`gpt/index.ts:217`), C5 consolidated, real-PUC browser test. -11. The bridge keeps consumed-id tombstones (`gpt/index.ts:1527`). -12. The tester cookie is not a security control (`tester_cookie.rs:3`). -13. Fastly constructs application state per request (`app.rs:146`); its - platform counter is a 60 s window with separate lookup/increment - (`rate_limiter.rs:40`). -14. The baseline auction client collapses every failure into an empty - array (`core/auction.ts:185-224`). -15. A single `/auction` request can carry several slots; concurrent calls - race today (`request.ts:31`) and share one fetch. - -## 4. Design gates - -### G1 — Trace identity, attempts, sampling, correlation - -- EC-derived auction ids (`publisher.rs:3237`) are never ingested. - Initial-HTML telemetry precedes page JS (`telemetry.rs:148`, - `publisher.rs:2452`); correlation is minted by whoever acts first: the - server for `nav_gen 0` (trace + signed authorization in `tsjs.boot`); - the client afterwards via `X-TSJS-Trace-Id` on page-bids (GET) and the - `/auction` POST, echoed back with - `ext.trusted_server.trace = {trace_id, auth, auction_id}`. -- **Attempt identity (closing the parent/child collision):** every render - attempt mints a client-side **`attempt_id`** (8-char `[a-z0-9]`, - CSPRNG) and carries nullable **`parent_attempt_id`** (fallback children - reference their parent). **The canonical attempt key is - `(publisher_domain, trace_id, attempt_id)`** — `attempt_id` alone (≈ 2.8 - T values) is not globally unique at production volume, so - `ts_render_attempts_v` keys and parent lookups use the full triple. - Uniqueness within a trace is guaranteed by a **per-trace live-set - collision check with retry** (the `NavigationSession` holds the issued - set; a collision re-draws). The tuple - `(trace_id, nav_gen, refresh_gen, slot)` remains a **grouping key only**. - Exactly one terminal event per attempt key (G4c) is a tested invariant. -- **Deterministic keyed sampling:** first 8 bytes of - `HMAC-SHA-256(sampling_key, trace_id)` as u64 BE; `sampled` iff - `u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate` finite in `[0, 1]`. -- **Cross-tier join:** equality key = the server telemetry - **`auction_id`** (UUID; the client column is `Nullable(UUID)` and - ingest validates canonical UUID syntax). Generations are client-side - only. **Join grains are explicit, and the slot grain goes through a - dedicated summary view.** `auction_events_raw` has multiple - `row_kind=slot` rows per slot (bid, provider, drop, selection), so a - `auction_id + slot` join multiplies rows; funnels therefore join against - **`ts_auction_slot_summary_v` — one row per `(auction_id, -canonical_slot)`** built from the `selection_summary` slot rows. - Auction-level joins hit the one totals/summary row per `auction_id`; - bid/drop/provider joins carry their real grain and are opt-in. **Client - `slot` and server `slot_id` use the same canonical normalization** - (lowercased, `-container` suffix stripped — the resolution rule of - `gpt/index.ts:112-138`), applied on both sides and tested. -- Cache-privacy invariant: traces/authorizations only in per-request - auction-bearing responses; such HTML is `private, no-store`. -- Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry - `{nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?, -auction_id?, t_rel_ms?}`. **`t_rel_ms`** (time, relative, in - milliseconds) is a bounded monotonic duration (`performance.now()` - truncated to u32 ms, relative to - navigation start) — the latency gates' basis; `received_at` is ingest - time and is never used as event time. `flow` is closed: - `ssat | prebid | page_bids | direct | fallback | system`. -- Traces are navigation-scoped; attempt counts key on `attempt_id`. - -### G2 — Render identity - -- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte - (`publisher.rs:3355`, `gpt/index.ts:1772`). Markup bids: existing - fallback chain. Renderer-only bids: server-minted token - `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, TTL 15 min, - one-time consumption. -- **Reservation store:** live registrations + tombstones (consumed / - stale / disposed) share one structure, **union capacity 320**; expired - entries pruned; **unexpired entries never evicted**; at capacity, new - registration refused with `registry_full`. Late prior-navigation - requests always meet suppression until original TTL (preserving - `gpt/index.ts:1527`). Test: >320 registrations, late oldest-id request. -- Client-Prebid keeps Prebid's `adId`; one store serves both paths. - Non-APS cache-path byte-identity regression tests. - -### G3 — Runtime ABI (exact-release) - -- IIFE-per-bundle with inlined imports (`build-all.mjs:46`, - `bundle.rs:23`); imports never share state (defect: - `core/context.ts:11` vs `permutive/index.ts:102`). Kernel only in - `tsjs-core`; `tsjs._internal = {release_id, registry}` frozen after - boot; core services constructed at boot; plugins via - `definePlugin({id, release, install})` with build-generated `release`; - `registry.get` succeeds only on equality; mismatch quarantines - (`abi_mismatch`/`bundle_partial`) loudly. -- **Boundary enforcement:** `import/no-restricted-paths` for layering, - plus a **custom scope-aware ESLint rule** for external-global access — - standard `no-restricted-properties`/`no-restricted-syntax` cannot - follow arbitrary aliasing, so the custom rule tracks member access to - `googletag`/`pbjs` through `window`/`globalThis`/`self` **and - same-file const aliases**; anything cleverer (cross-module smuggling) - is caught by review, and the claim is scoped to exactly that. Adapters - are the only access to external ad-tech globals; kernel/messaging - necessarily touch `window.tsjs` and `postMessage`. - -### G4 — Render lifecycle - -**G4a — Physical request cycles.** Intents (both classes, one causal -queue): any `display()` under disabled initial load is retired at -issuance regardless of caller; hindsight zero-request intents expire at -2 s with `intent_no_request`; **any later request-capable intent — same -class or opposite — supersedes a pending uncertain intent**, and if the -uncertain one could still be in flight, the next `slotRequested` is -ambiguous → quarantine. Cycles open only on `slotRequested` (causal -head; SRA = one per slot per batch) and close on `slotRenderEnded` -(`responseIdentifier` dedups drain). **Each attributable `slotRequested` -emits exactly one `request_cycle_started` event** — the materialized -denominator the fill and attribution gates divide by (`attempt_started` -is not equivalent: an attempt can fail before producing a physical -request). One outstanding TS cycle per slot; one queued replacement. -Attribution requires exactly one outstanding TS cycle and no overlap; -otherwise `cycle_unattributable`, fail closed. **No timeout re-arm** — -re-arm only on count-based drain, safe TS-owned destroy/redefine, or page -end; unissued intents are NavigationSession children; physical state is -RuntimeSession. Deterministic-harness CI + -the release-gating real-GAM suite (Appendix A.3). - -**G4b — Two-phase acknowledgement (the nonce is minted by the claim, not -carried in the request).** The baseline PUC request is `{message, adId}` - -- a `MessagePort` — it cannot carry a nonce or generations, and the nonce - can only exist **after** a claim succeeds. So the protocol is two phases: - -* **Phase 1 — claim (on the incoming `Prebid Request`):** validate source - ownership (§6.8), the reserved `adId` against the reservation store, and - the internal current `nav_gen`/`refresh_gen` **held in the kernel** - (not in the message); on success, **mint the per-attempt 128-bit CSPRNG - nonce**, bind notifications, reply over the `MessagePort` (the renderer - depends on this reply to settle its PUC promise), and — for the APS - path — hand the nonce to the renderer document in the response. -* **Phase 2 — acknowledgement (later document/runner messages):** validate - source, nonce, token, `nav_gen`, `refresh_gen`. Navigation/supersession - invalidates the nonce; late acks → `stale_navigation`. Deadlines: - document 3 s, runner 10 s, adm 5 s. - -Per render path: - -1. **APS-PUC** (baseline transport): the Phase-1 reply travels over the - `MessagePort`; the response also transfers the freshly minted nonce - into the renderer document (`ports.length` checks, exact-key replies, - one-shot latch, port close); the document then posts authenticated - `renderer_document_loaded` and the accepted/failed result to the top - window (Phase 2). -2. **Generic ADM/cache-PUC:** **the acceptance observation lives in the - trusted owner, not the creative document** — the owner observes its - own iframe's `load`/`error` events and emits `adm_document_loaded`; - the nonce never enters the bidder realm (an injected reporter would - hand bidder-controlled code the acceptance credential and let it - trigger `burl` early — revision 8's reporter is withdrawn). -3. **Direct APS:** the kernel is the frame parent; the baseline - parent-postMessage branch is already kernel-observed. -4. **Direct ADM/cache:** as (2), owner-observed `load`/`error`. - -**G4c — Honest observations; one terminal event.** Observations: -`gam_nonempty`, `gam_empty`, `gam_collapsed{action: resized | guarded}`, -`renderer_document_loaded`, `runner_loaded`, `runner_failed`, -`adm_document_loaded`. **The terminal is one discriminated event: -`render_terminal{outcome: accepted | failed | no_bid | cancelled, -reason?}` — exactly one per `attempt_id`** (replacing separate -accepted/fail events the schema could not reconcile). A parent attempt -whose GAM cycle ends empty emits `render_terminal{failed, gam_empty}` -**before** its fallback child starts. Post-acceptance runner failure is -an observation only (no billing-failure event exists — no honest -producer). No observation claims paint. The baseline resize -(`gpt/index.ts:217`) stays a sanctioned, guarded exception. - -**G4d — Notifications.** APS carries neither `nurl` nor `burl` -(`aps.rs:839`) — excluded entirely. For carrying paths: bind per flow -(PUC: owned matched bridge claim; direct: validated render start — -server must preserve + macro-expand the URLs (`formats.rs:423` omits), -client must parse + https-validate (`core/auction.ts:43` drops); -fallback: attributed parent `gam_empty` immediately before child -render). `nurl` at bind; `burl` at `accepted`; idempotency key -`(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`. -**Dispatch mechanics (normative):** macros are expanded server-side -only; the client fires exactly one -`fetch(url, {method: "GET", mode: "no-cors", credentials: "omit", -redirect: "follow", referrerPolicy: "no-referrer", keepalive: true})` -and awaits its settlement. **There is no `Image()` fallback** — a -detached image request would construct a separate credentialed, -referrer-bearing request outside this privacy contract; a request that -rejects (construction error) or resolves to a network failure emits -`notification_sent{result: failed}` and stops. No retries. Every -dispatch emits -`notification_sent{kind, notif_id, result: queued | failed}` with the -**server-minted `notif_id`** (12-char token delivered with the bid). -Duplicates: hermetic exactly-once proof + production detection alarm + -billing reconciliation (lossy telemetry cannot prove a zero). - -**G4e — Fallback.** Opt-in; child attempt (own `attempt_id`, -`parent_attempt_id`, `flow = fallback`); renders only after the parent's -attributed `render_terminal{failed, gam_empty}`; publisher-initiated or -unattributable never triggers; timeouts never render. - -**G4f — Direct `/auction` lifecycle and the AuctionBatch.** A single -`/auction` fetch may serve several slots, so cancellation is -batch-aware: an **`AuctionBatch`** owns the fetch (its `AbortController`) -and the child `RenderAttempt`s. Supersession cancels **children -individually** (`render_terminal{cancelled}`); the fetch aborts only -when every child is dead or the batch times out or its navigation -disposes; every response bid is filtered through the **currently live** -child identity before any effect. Tests: partial overlap, full overlap, -timeout, navigation disposal, reversed responses. The auction client -returns a discriminated result — `{ok: bids[]} | {error: -"auction_timeout" | "network_error" | "http_error" | "invalid_response"}` -(§3.14); only a parsed empty response is `no_bid`. **After the batch -processes every response bid, each still-live child with no valid -matching winner is terminated `render_terminal{no_bid}`** — a response -carrying only slot X never leaves slot Y's child nonterminal, so -`RequestAdsResult` always settles. Public API: -`tsjs.requestAds(options): Promise`, -`RequestAdsResult = {traceId, slots: [{slot, outcome, reason?}]}`, -settling when every child attempt is terminal. - -**G4g — Mid-attempt configuration, honestly scoped.** Attempts snapshot -configuration at creation. **Commit = the earliest irreversible action** -(first of: notification dispatch, `bridge_response_sent`, first DOM -insertion), with the generation/kill check immediately before each. -**The kill switch's delivery is page-bound:** already-loaded pages have -no push channel, so live switch state travels only on responses the -page later fetches — page-bids and `/auction` responses carry -`ext.trusted_server.switches`, and new HTML carries current state. The -guarantee is therefore scoped: the switch affects attempts created -after the page received switch state; SSAT attempts on already-loaded -pages are unaffected by design, and the spec says so rather than -implying a live channel that does not exist. - -### G5 — Deployment contracts - -- Config verification per §0. Assets pre-materialized at release - publication (config is a release-time input); serving is lookup-only; - unknown vector = build error; unknown hash = `410 no-store`; exact - match = `public, max-age=31536000, immutable`. -- **Internal route families — five** (renderer, client-events, - CSP-report, `/_ts/trace-auth`, and the **admin issuance family** - `/_ts/admin/*` — diagnostic-credential and probe-authorization, §5.3): - all dispatch before auth/EC/publisher/integration filters (`app.rs:709` - orders these wrong today); all methods + version prefixes reserved - locally (405 + `Allow` + `no-store`; unknown version 404 `no-store`; no - publisher fall-through, `adapter-spin app.rs:804`); no - body/cookie/authorization forwarding to the publisher. **Per-family - origin/auth policy:** client-events + trace-auth strict normalized - same-origin; CSP admits opaque/`null` origins with path identity + - limits; renderer is a public GET validated by version/path; **the admin - family sits behind operator authentication with its own rate limits and - exists on all adapters that expose an admin plane** (elsewhere it is - absent, and diagnostic/probe modes are unavailable there — stated, not - implied). All five families appear in the four-adapter parity matrix. -- Ingest routes in all four adapters; Fastly has real sinks; others - accept-count-drop (DR-5). §5.6 schemas deploy before writers. - -## 5. Observability - -### 5.1 Wire payload and field matrix +- **Baseline:** `origin/rc/july` @ `905984e62` ("Prevent APS renderer document + clipping"), including `248fe9558` (PUC/MessageChannel and collapsed-shell + rendering) and `ed38f3e13` (PUC overflow prevention). File-and-line references + and commit hashes describe that baseline and are evidence, not permanent API + contracts. +- **Compatibility:** this is a coordinated hard cutover. No backward-compatible + aliases, dual APIs, or N/N-1 browser/server protocol are required. +- **Decision:** this document covers APS render correctness, the TSJS architecture + needed to make that correctness durable, and preservation or explicit + architectural replacement of every TSJS concept present on the baseline. It + does not add an external telemetry system or release experimentation. + +## 0. Scope and constraints + +### 0.1 Goals + +1. An accepted APS bid renders through every supported Trusted Server path: + SSAT/GPT, the Trusted Server Prebid adapter through GAM Universal Creative, + SPA page bids, and direct `/auction` rendering. +2. Render ownership, identity, and completion are explicit. Races, stale SPA + work, ambiguous GPT events, duplicate creative requests, and timeouts settle + deterministically instead of failing silently. +3. TSJS has one runtime kernel, bounded state, explicit lifetimes, and enforced + dependency boundaries. Integration bundles cannot accidentally create + independent copies of shared state. +4. The Rust auction result, publisher projection, TypeScript parser, Prebid + registration, GPT targeting, Universal Creative bridge, and direct renderer + agree on one APS descriptor and identity contract. +5. Security boundaries are testable: untrusted creative messages cannot claim a + different slot or attempt, replay a consumed capability, or revive work from a + prior navigation. +6. Existing non-APS rendering behavior remains correct unless this design + explicitly replaces a shared lifecycle surface. +7. Every TSJS behavior on the exact `rc/july` baseline is either preserved, + rebuilt behind the new architecture, or explicitly superseded by a named and + tested replacement contract. No TSJS behavior may disappear silently merely + because its old global, wrapper, bootstrap, or carrier is deleted. + +### 0.2 Non-goals + +- No change to analytics/telemetry schemas, durable data systems, billing, + experimentation, or deployment routing. Those belong to separate designs. +- No change to the APS upstream OpenRTB endpoint contract, including APS's + deliberate absence of `nurl` and `burl`. +- No rewrite of Prebid.js itself or of the decoupled Prebid strategy. +- No refactor of unrelated integration internals. They receive only the thin + registration/bootstrap changes required by the new TSJS runtime, plus any + mechanical disposal or adapter injection needed to preserve their baseline + behavior. + +Existing local render tracing, GPT diagnostics, logging, counters, debug output, +and telemetry integrations remain functional through the cutover. They may move +behind the runtime event bus or the final diagnostics namespace, but their +observable concepts and non-interference guarantees are in scope. Correctness must +not depend on a new event reaching an external sink. Any new analytics contract +requires a separate design. + +### 0.3 Architectural rules + +- Make invalid states unrepresentable where practical and reject them at the + boundary otherwise. +- Every asynchronous operation belongs to a runtime, navigation, auction batch, + or render-attempt lifetime and has a deterministic disposer. +- Every render attempt reaches exactly one terminal result in memory. +- A timeout cancels or fails only the object it owns; shared work is aborted only + when no live child still needs it. +- Ad-tech globals are accessed only through adapters. +- Cross-window messages are versioned, exact-shaped, capability-bound, and + source/port checked. +- No correctness path waits for logging, telemetry, notification delivery, or any + other side effect unrelated to rendering. + +### 0.4 `rc/july` TSJS concept-adoption contract + +The exact baseline is the whole observable TSJS system at +`origin/rc/july@905984e62`, not only the three commits after this design branch's +merge base. It includes `crates/trusted-server-js/lib/src/**`, its build and test +tooling, the TSJS behavior embedded in `gpt_bootstrap.js` and +`gpt_diagnostics_bootstrap.js`, and the browser tests that exercise those sources. +The in-spec executable manifest in §0.5 records that tree and commit. If the local +`origin/rc/july` ref moves, implementation stops before code +changes, diffs the old and new tips across those paths, and updates this ledger and +its tests deliberately. A moving branch is never absorbed implicitly. + +Each ledger entry has one of these dispositions: + +- **Preserve:** retain the observable behavior and its failure semantics. +- **Rebuild:** retain the outcome but replace the old mechanism with the runtime, + adapter, service, or integration module named here. +- **Supersede:** deliberately replace an old mechanism with a stricter named + contract. The ledger must state the behavioral change and prove either that the + new owner makes the old compensation unnecessary or that the new terminal + failure is complete, bounded, and preferable to a partial second runtime. +- **Exclude:** keep the existing feature untouched because it is not TSJS work; + this disposition cannot be used for a file under the TSJS baseline inventory. + +Hard cutover authorizes removal of old mechanisms and names. It does not authorize +silent loss of a ledger outcome. An observable outcome may change only through an +explicit **Supersede** entry that names the old and final behavior, gives the +architectural reason, and has boundary tests for the replacement contract. A source +deletion is complete only when its ledger entry has a passing replacement contract +or that explicit supersession proof. + +| ID | Baseline TSJS concept | Disposition and final owner | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RCJ-CORE-01` | Core config/context, callback queue, auction parsing, direct request/rendering, SPA generation checks, and shared helpers continue to serve every enabled integration. | **Rebuild:** kernel, services, and composition root; exact behavioral corpus runs before and after the switch. | +| `RCJ-CORE-02` | Programmatic ad-unit registration can drive direct `/auction`; core also exposes version/queue, placeholder render helpers, mutable generic config, and the local logger. | **Preserve/supersede:** §5.4 defines the exact final API; typed registration/request APIs and immutable config replace placeholders/mutable config; logger methods/default remain, while invalid levels now throw without mutation instead of being retained with warn fallback. | +| `RCJ-BOOT-01` | The edge-injected `gpt_bootstrap.js` duplicates initial-load tracking, slot handoff, hydration scheduling, GPT definition/targeting/display/refresh, and can render initial ads without the main TSJS bundle. | **Rebuild/supersede:** the committed runtime owns all normal GPT behavior. Missing/partial bundles intentionally settle through the terminal non-rendering fallback in §5.3; no bootstrap may construct a degraded GPT runtime. | +| `RCJ-TRACE-01` | Render tracing records one honest impression timeline, bounded history, current-slot state, DOM stamps/badges, local overlay, no stale auction attribution, and emits `tsjs:adRendered`. | **Rebuild/supersede:** lifecycle diagnostics subscriber and `tsjs.diagnostics.renderTrace`; its subscription replaces the mutable globals and CustomEvent, and no integration writes trace state directly. | +| `RCJ-GPT-01` | A TS fallback and a later publisher `defineSlot` share one physical GPT slot and one initial request; ownership transfer prevents later TS destruction. | **Rebuild:** GPT adapter plus slot service handoff record; no integration-owned function sentinels or duplicate wrappers. | +| `RCJ-GPT-02` | Responsive/hydrated slot resolution chooses the unique active placement, recovers DOM replacement, and never silently chooses an ambiguous sibling. | **Rebuild:** navigation-scoped aliases plus runtime-owned DOM binding/reconciliation. | +| `RCJ-GPT-03` | Native publisher GPT calls, service state, SRA, disabled initial load, refresh options, targeting cleanup, and publisher-owned slots retain their native semantics. | **Preserve/Rebuild:** the sole GPT adapter owns interception and event fan-out; publisher activity never becomes TS-owned work. | +| `RCJ-GPT-04` | A TS-owned PUC response may resize only its authenticated still-collapsed ordinary 1×1 GAM shell, never unrelated, anchor, fixed, sticky, or already-expanded frames. | **Preserve/Rebuild:** current render attempt owns one guarded resize after a response is successfully posted. | +| `RCJ-PREBID-01` | The publisher-specific artifact is pure Prebid.js; the Trusted Server shim is a separate TSJS integration module, and the external bundle remains independently useful if that module fails. | **Preserve/Rebuild:** external artifact plus Prebid adapter/integration module; TS code is not vendored into the external Prebid artifact. | +| `RCJ-PREBID-02` | Missing, late, duplicate, older, or partial Prebid artifacts fail safely: publisher queues drain, TS refresh handling is not installed without a real API, and installation is idempotent. | **Preserve/Rebuild:** artifact watchdog plus release-matched module transaction and bounded readiness queue. | +| `RCJ-PREBID-03` | Adapter manifests distinguish module names from registered bidder codes/aliases; client-side bidder coverage, user-ID modules, EIDs, native bids, and publisher callbacks keep working. | **Preserve:** typed artifact contract and black-box artifact tests; TS-owned bid identities alone are replaced. | +| `RCJ-PREBID-04` | Configured GAM-path exclusions remove only matching slots from the synthetic Prebid refresh auction while clearing stale TS keys and retaining every slot/options in the GPT refresh. | **Preserve/Rebuild:** one refresh policy in the Prebid integration module over the GPT adapter; global, explicit, mixed, all-excluded, and fail-open path cases remain exact. | +| `RCJ-APS-01` | First-class APS OpenRTB admission, typed descriptor projection, direct rendering, Trusted Server Prebid-adapter rendering, and PUC rendering remain supported. | **Preserve/Rebuild:** Rust admission plus the shared render lifecycle described in §§3–4. | +| `RCJ-APS-02` | `bid.meta`, generated Prebid `adId`, upstream bid-id fallback, and old `hb_adid` precedence carried APS identity through lossy boundaries. | **Supersede:** the server-minted `r1_` reservation is the only TS PUC authority; native Prebid IDs and PBS Cache UUIDs remain byte-preserved for their own purposes. | +| `RCJ-APS-03` | PUC uses one-use ports, APS callbacks—not script load—determine success, renderer tombstones are bounded, and lifecycle callbacks cannot corrupt later attempts. | **Preserve/Rebuild:** bridge dispatcher, owner-control channel, reservation service, and terminal latch. | +| `RCJ-APS-04` | The PUC document, renderer document, and descendant creative receive the winning dimensions without default margins, scrollbars, overflow, or clipping. | **Preserve:** exact CSS/DOM sizing contract in §4.4 and three-level browser assertions. | +| `RCJ-CREATIVE-01` | Auction creative sanitization remains opt-in/default-off, rewriting retains its existing independent setting, and every delivery path observes the same configured processing boundary. | **Preserve:** creative integration module and server processing; this design does not silently enable sanitization or broaden rewriting. | +| `RCJ-CREATIVE-02` | Opaque-origin click recovery accepts only validated absolute HTTP(S) navigation, persists the validated URL, rejects non-network schemes, and keeps creative sandbox isolation. | **Preserve/Rebuild:** creative integration module over shared origin/DOM helpers, with unit and real-browser sandbox coverage. | +| `RCJ-CREATIVE-03` | `tscreative.installGuards/setConfig/getConfig`, `tsCreativeConfig`, automatic install, click-guard default-on, and render-guard default-off control the creative browser guards. | **Preserve/supersede:** `CreativeBootV1` retains the defaults and the integration module auto-installs transactionally; mutable/install command globals are deleted and immutable `tsjs.boot.creative` is the only inspection/config surface. | +| `RCJ-DIAG-01` | GPT runtime diagnostics reports raw GPT observations, exact slot binding/replacement, request cycles/timing, bounded export, overlay/badges, and no lifecycle interference. | **Preserve/Rebuild:** diagnostics integration module consumes the GPT adapter event stream and exposes `tsjs.diagnostics.gpt`; it never installs a second GPT control wrapper. | +| `RCJ-INT-01` | DataDome, Didomi, Google Tag Manager, Lockr, Osano, Permutive, Sourcepoint, and Testlight retain their current proxy guards, configuration, consent/segment, queue, and timing behavior. | **Preserve:** thin transactional integration modules plus complete pre/post-cutover black-box suites; internal feature behavior is otherwise unchanged. | +| `RCJ-INT-02` | Shared script, beacon, DOM-insertion, scheduling, origin, and async helpers retain per-integration matching and failure isolation. | **Rebuild where shared:** helper factories with integration-owned configuration; one module failure cannot unwind another integration module or publisher code. | +| `RCJ-QUAL-01` | Lint covers production source, tests, scripts, diagnostics, and build code; TypeScript and artifact checks cover the actual shipped combinations. | **Preserve/strengthen:** full-package lint/typecheck plus architecture, maximal-bundle, generated-artifact, browser, and retained-heap gates. | + +The commit clusters that exposed these concepts include the render-trace series +starting at `966c8569c`; GPT recovery/handoff/responsive/native-behavior commits +`4f45974e5`, `9b1985c8b`, `340d1efb4`, `0fdd13e7d`, `ca678fe69`, and +`b200be53c`; Prebid decoupling/resilience and refresh commits `001ad385c`, +`cdff89706`, `f3dc6ba70`, `60a85e661`, and `a007bd0d0`; creative hardening +commits `1929dc83a`, `fde835110`, `9b21ba450`, `20977105f`, `1db074d4b`, and +`3d9e2b693`; GPT diagnostics `11a4a7d25`; full-package lint `941473407`; APS +admission/rendering commits from `f916ddf90` through `a08bebfbd`; and the final +PUC/sizing chain `248fe9558`, `ed38f3e13`, `905984e62`. The executable tree +inventory, not this illustrative hash list, is the completeness authority. + +### 0.5 In-spec baseline mapping manifest + +To keep this a one-file design, the completeness manifest is embedded here instead +of creating another repository artifact. The implementation's first contract test +extracts the `rcjuly-tsjs-manifest-v1` JSON block, enumerates each directory +`includeRoot` plus every exact mapped file at the pinned commit with `git ls-tree`, +and requires every enumerated file to match at least one `exact`, `prefix`, or +`prefixes` mapping. A path receives the union of every matching row. Every +file under `lib/src` must receive at least one non-`RCJ-QUAL-01` id. A mapping that +matches no pinned path also fails, preventing stale rows. Moving the baseline runs +this check before implementation and requires an explicit manifest/ledger update for +every new, removed, or renamed path. + +```json rcjuly-tsjs-manifest-v1 +{ + "version": 1, + "baseline": "905984e62a0858c53d9f0ff6dd3a1bf190cf311d", + "includeRoots": [ + "crates/trusted-server-js/lib", + "crates/trusted-server-integration-tests/browser/tests" + ], + "mappings": [ + { + "exact": [ + "crates/trusted-server-js/lib/.gitignore", + "crates/trusted-server-js/lib/.prettierignore", + "crates/trusted-server-js/lib/.prettierrc.json", + "crates/trusted-server-js/lib/eslint.config.js", + "crates/trusted-server-js/lib/package-lock.json", + "crates/trusted-server-js/lib/package.json", + "crates/trusted-server-js/lib/tsconfig.json", + "crates/trusted-server-js/lib/vite.config.ts", + "crates/trusted-server-js/lib/vitest.config.ts" + ], + "ids": ["RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-js/lib/build-all.mjs", + "crates/trusted-server-js/lib/src/index.ts" + ], + "ids": ["RCJ-CORE-01", "RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-js/lib/build-prebid-external.mjs", + "crates/trusted-server-js/lib/test/build-prebid-external.test.mjs", + "crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs" + ], + "ids": ["RCJ-PREBID-01", "RCJ-PREBID-02", "RCJ-PREBID-03", "RCJ-QUAL-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/core/", + "ids": ["RCJ-CORE-01"] + }, + { + "exact": ["crates/trusted-server-js/lib/src/core/log.ts"], + "ids": ["RCJ-CORE-02"] + }, + { + "exact": [ + "crates/trusted-server-js/lib/src/core/config.ts", + "crates/trusted-server-js/lib/src/core/index.ts", + "crates/trusted-server-js/lib/src/core/registry.ts", + "crates/trusted-server-js/lib/src/core/request.ts" + ], + "ids": ["RCJ-CORE-02"] + }, + { + "exact": ["crates/trusted-server-js/lib/src/core/trace.ts"], + "ids": ["RCJ-TRACE-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/shared/", + "ids": ["RCJ-CORE-01", "RCJ-INT-02"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/aps/", + "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/creative/", + "ids": ["RCJ-CREATIVE-01", "RCJ-CREATIVE-02", "RCJ-CREATIVE-03"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/gpt/", + "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03", "RCJ-GPT-04"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/", + "ids": ["RCJ-DIAG-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/prebid/", + "ids": [ + "RCJ-PREBID-01", + "RCJ-PREBID-02", + "RCJ-PREBID-03", + "RCJ-PREBID-04" + ] + }, + { + "prefixes": [ + "crates/trusted-server-js/lib/src/integrations/datadome/", + "crates/trusted-server-js/lib/src/integrations/didomi/", + "crates/trusted-server-js/lib/src/integrations/google_tag_manager/", + "crates/trusted-server-js/lib/src/integrations/lockr/", + "crates/trusted-server-js/lib/src/integrations/osano/", + "crates/trusted-server-js/lib/src/integrations/permutive/", + "crates/trusted-server-js/lib/src/integrations/sourcepoint/", + "crates/trusted-server-js/lib/src/integrations/testlight/" + ], + "ids": ["RCJ-INT-01", "RCJ-INT-02"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/core/", + "ids": ["RCJ-CORE-01", "RCJ-CORE-02", "RCJ-QUAL-01"] + }, + { + "exact": ["crates/trusted-server-js/lib/test/core/trace.test.ts"], + "ids": ["RCJ-TRACE-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/shared/", + "ids": ["RCJ-INT-02", "RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.json" + ], + "ids": ["RCJ-APS-01", "RCJ-APS-03", "RCJ-QUAL-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/aps/", + "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/creative/", + "ids": ["RCJ-CREATIVE-01", "RCJ-CREATIVE-02", "RCJ-CREATIVE-03"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/gpt/", + "ids": [ + "RCJ-BOOT-01", + "RCJ-GPT-01", + "RCJ-GPT-02", + "RCJ-GPT-03", + "RCJ-GPT-04" + ] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/", + "ids": ["RCJ-DIAG-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/prebid/", + "ids": [ + "RCJ-PREBID-01", + "RCJ-PREBID-02", + "RCJ-PREBID-03", + "RCJ-PREBID-04" + ] + }, + { + "prefixes": [ + "crates/trusted-server-js/lib/test/integrations/datadome/", + "crates/trusted-server-js/lib/test/integrations/didomi/", + "crates/trusted-server-js/lib/test/integrations/google_tag_manager/", + "crates/trusted-server-js/lib/test/integrations/lockr/", + "crates/trusted-server-js/lib/test/integrations/osano/", + "crates/trusted-server-js/lib/test/integrations/permutive/", + "crates/trusted-server-js/lib/test/integrations/sourcepoint/" + ], + "ids": ["RCJ-INT-01", "RCJ-INT-02", "RCJ-QUAL-01"] + }, + { + "exact": ["crates/trusted-server-core/src/integrations/gpt_bootstrap.js"], + "ids": ["RCJ-BOOT-01", "RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03"] + }, + { + "exact": [ + "crates/trusted-server-core/src/integrations/gpt_diagnostics.rs", + "crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js" + ], + "ids": ["RCJ-DIAG-01"] + }, + { + "exact": ["crates/trusted-server-core/src/integrations/gpt.rs"], + "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03", "RCJ-GPT-04"] + }, + { + "exact": ["crates/trusted-server-core/src/integrations/prebid.rs"], + "ids": [ + "RCJ-PREBID-01", + "RCJ-PREBID-02", + "RCJ-PREBID-03", + "RCJ-PREBID-04" + ] + }, + { + "exact": ["crates/trusted-server-core/src/integrations/aps.rs"], + "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"] + }, + { + "exact": [ + "crates/trusted-server-core/src/integrations/datadome.rs", + "crates/trusted-server-core/src/integrations/datadome/protection.rs", + "crates/trusted-server-core/src/integrations/datadome/protection_scope.rs", + "crates/trusted-server-core/src/integrations/didomi.rs", + "crates/trusted-server-core/src/integrations/google_tag_manager.rs", + "crates/trusted-server-core/src/integrations/lockr.rs", + "crates/trusted-server-core/src/integrations/mod.rs", + "crates/trusted-server-core/src/integrations/osano.rs", + "crates/trusted-server-core/src/integrations/permutive.rs", + "crates/trusted-server-core/src/integrations/sourcepoint.rs", + "crates/trusted-server-core/src/integrations/testlight.rs" + ], + "ids": ["RCJ-INT-01", "RCJ-INT-02"] + }, + { + "exact": ["crates/trusted-server-core/src/trace_cookie.rs"], + "ids": ["RCJ-TRACE-01"] + }, + { + "exact": ["crates/trusted-server-core/src/tsjs.rs"], + "ids": ["RCJ-CORE-01", "RCJ-CORE-02", "RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts" + ], + "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03", "RCJ-DIAG-01"] + }, + { + "prefix": "crates/trusted-server-integration-tests/browser/tests/", + "ids": ["RCJ-CORE-01", "RCJ-INT-01", "RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts" + ], + "ids": ["RCJ-DIAG-01"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/tests/nextjs/navigation.spec.ts" + ], + "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts" + ], + "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts" + ], + "ids": ["RCJ-CREATIVE-01", "RCJ-CREATIVE-02", "RCJ-CREATIVE-03"] + } + ] +} +``` +## 1. Problem statement and evidence + +APS demand is integrated server-side, but APS creatives do not render reliably. +Four serial fixes—the `bid.meta` carrier, decoupled shim, `hb_adid` fallback, and +the baseline PUC/collapsed-shell fix—each repaired one edge while leaving other +independent failure points. The common failure is architectural: identity and +state are copied across loosely coordinated server, GPT, Prebid, PUC, and iframe +code, and many failures are swallowed. + +The TSJS library has the same structural problem: two large GPT/Prebid modules, +duplicated ES5 and TypeScript behavior, imports in separately built IIFEs that do +not share module state, global expandos, multiple GPT wrappers, and asynchronous +work with no common owner. + +### 1.1 Supported flows + +| Flow | Auction source | Render owner | APS route | +| --------- | ----------------------------------------------- | ------------------------ | ------------------------------------------------------- | +| SSAT | `tsjs.boot.auctionProjection` | GPT integration | GAM Universal Creative → TS bridge → APS renderer | +| Prebid | Trusted Server Prebid adapter | Prebid + GPT integration | GAM Universal Creative → TS bridge → APS renderer | +| Page bids | `/_ts/page-bids` | GPT integration | GAM Universal Creative → TS bridge → APS renderer | +| Direct | `/auction` | render service | TS-owned iframe → APS renderer | +| Fallback | opt-in child of an attributable empty GAM cycle | render service | direct APS or direct ADM, according to the returned bid | + +### 1.2 Known failure surfaces + +| Area | Failure | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Admission | A configured mediator can discard direct-provider bids; scripts can be rejected by policy; strict dimensions and APS response validation can drop bids without a useful local reason. | +| Identity | PBS Cache UUID, upstream APS bid id, Prebid `adId`, GAM `hb_adid`, DOM id, and server slot id are different identities and have been conflated. GAM targeting values are capped at 40 characters. | +| GPT | `display()` under disabled initial load does not request; event listeners can be installed too late; multiple refresh wrappers and concurrent requests race; SafeFrame obscures frame ancestry. | +| Bridge | A `Prebid Request` can be duplicated, replayed, sent by a wrong frame, or arrive after navigation. A bare bid id is not enough to establish ownership. | +| Renderer | The opaque sandbox cannot observe HTTP failure; descriptor validation exists in Rust, TypeScript, and embedded ES5; CSP or runner loading can fail after the iframe loads. | +| Direct auction | One fetch can contain several slots, but current cancellation and result handling are not batch-aware; failures collapse to an empty array. | +| Bootstrap | Server bootstrap and bundle initialization can both believe they own runtime setup; a hung bundle can race the no-bundle fallback. | +| Lifecycle | There is no shared definition of attempt, ownership, supersession, terminal completion, or disposal. | + +## 2. Required behavior + +### 2.1 Outcome contract + +Each render attempt has one of four terminal outcomes: + +```ts +type RenderFailureReason = + | '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' + +type RenderOutcome = + | { outcome: 'accepted' } + | { outcome: 'no_bid' } + | { outcome: 'failed'; reason: RenderFailureReason } + | { + outcome: 'cancelled' + reason: 'caller_aborted' | 'superseded' | 'navigation_disposed' + } ``` -{ v: 1, traces: [ { trace_id, auth, events: [ - { nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?, - auction_id?, t_rel_ms?, t, ...fields } ] } ] } + +`accepted` means the path-specific completion authority reported success: + +- APS: the sandboxed renderer document accepted the descriptor and the queued APS + `prebid/creative/render` event invoked its success callback. APS owns the runner and + its promise that it invokes this callback only after committing the nested creative + iframe; Trusted Server cannot independently prove that promise for mutable upstream + bytes. Loading the runner script alone is not acceptance. +- Direct ADM/cache: the TS-owned iframe fired its first `load` before timeout. +- PUC ADM/cache: the TS-authored PUC owner reported its owned iframe's first + `load` over the bound owner channel. + +It does not claim that pixels were viewable. `slotRenderEnded{isEmpty:false}` is +evidence that GAM injected a creative, not evidence that APS completed. + +`no_bid` is reserved for an explicit, successfully parsed server auction decision +with no valid winner for that slot. An attributable GPT +`slotRenderEnded{isEmpty:true}` is +`failed{reason:'gam_empty'}` so an opt-in fallback can name its exact parent cause. +Network, timeout, HTTP, parse, descriptor, bridge, and renderer failures are not +converted to `no_bid`. + +Every attempt owns one terminal latch. All competing callbacks, ports, iframe +events, timers, aborts, and navigation disposal race through that latch. The first +valid terminal transition wins, disposes attempt resources, and makes every later +signal inert. + +### 2.2 Identity model + +The implementation keeps these identities distinct: + +| Identity | Purpose | Rules | +| ----------------------- | -------------------------------- | -------------------------------------------------------------- | +| server slot id | auction and publisher projection | exact, case-sensitive, 1–256 UTF-8 bytes, no NUL/control | +| programmatic slot id | direct-auction registration | validated `code`; same bound; exact request/result identity | +| DOM/container alias | locating a page element | separate collision-detecting index; ambiguous aliases fail | +| upstream bid id | provider provenance | 1–64 UTF-8 bytes, no NUL/control, unique in provider response | +| candidate id | mediator round-trip | server-minted opaque 12-character token; never an ordering key | +| PBS Cache UUID | cache transport lookup | preserved byte-for-byte as `cacheId`; never bridge authority | +| native Prebid `adId` | non-TS Prebid renderer lookup | untouched for native bids; never entered in the TS store | +| renderer reservation id | every TS-owned PUC capability | `r1_` plus 22 base64url characters; exact `hb_adid`/TS `adId` | +| attempt id | in-page lifecycle ownership | `a1_` plus 22 base64url characters; navigation-unique | +| lifecycle ticket | cross-window capability | `t1_` plus 22 base64url characters; one-use and attempt-bound | +| renderer nonce | renderer-document capability | `n1_` plus 22 base64url characters; one-use and attempt-bound | + +Every TS-owned PUC source—APS, inline ADM, or cache—receives a renderer reservation +id, and that id is copied exactly to GAM `hb_adid`. For the Trusted Server Prebid +adapter bid, it also replaces the TS bid's generated `adId` before targeting; +native Prebid bids are untouched. The server creates the id from 16 CSPRNG bytes +encoded as unpadded base64url and prefixed with `r1_`; it retries a response-local +collision at most eight times, then fails the bid with +`identity_generation_failed`. The browser rejects a collision with any live/ +tombstoned reservation as `reservation_collision`. Cache UUID and upstream/provider +bid id stay inside the tagged render source/provenance and are never fallback bridge +credentials. + +Attempt ids require no unbounded issued-id set. At `NavigationSession` creation the +browser obtains eight CSPRNG bytes with `crypto.getRandomValues` and keeps one +unsigned 64-bit attempt ordinal as two 32-bit words. For each new attempt it +increments the ordinal, concatenates the navigation prefix with the big-endian +ordinal, base64url-encodes those 16 bytes without padding, and prefixes `a1_`. The +fixed navigation prefix plus never-reused ordinal guarantees navigation-local +uniqueness. Prefix-generation failure or ordinal exhaustion refuses the new attempt +with `identity_generation_failed`; the ordinal never wraps. The active-attempt index +contains at most one attempt per admitted slot and is therefore capped by +`MAX_ACTIVE_SLOT_RECORDS = 256`; terminal settlement removes the strong entry, while +generation plus the nonreused ordinal makes stale callbacks inert without retaining +old ids. + +One runtime-owned capability registry stores lifecycle tickets and their tombstones +with a shared capacity of 320. Before minting it prunes entries whose fixed +three-second lifetime has expired; unexpired entries are never evicted. A ticket is +16 fresh CSPRNG bytes encoded as the fixed `t1_` form and checked against every live/ +tombstoned ticket. A collision retries at most eight total draws, then fails the +attempt with `identity_generation_failed`. Capacity exhaustion before a successful +draw refuses the outer PUC response and fails the attempt with +`capability_registry_full`. Consumption/disposal replaces the live entry with a +tombstone carrying the same original expiry; it never extends the lifetime. + +Renderer nonces use the same eight-draw CSPRNG/collision rule in a separate live +registry capped at 256, at most one `n1_` value per active attempt. They need no +tombstone: the exact frame, port, attempt id, and generation remain mandatory, and +attempt disposal closes the channel and removes the live nonce before any later +attempt can act. Nonce capacity fails the attempt with `capability_registry_full`; +collision exhaustion is `identity_generation_failed`. Neither registry falls back +to timestamps, `Math.random`, truncation, or eviction. + +### 2.3 Slot registry and bounded reservations + +One runtime-scoped slot service owns the registry: + +- `WeakMap` for GPT object identity; +- exact registered-slot-id index covering server and programmatic registrations; +- exact GPT ad-unit-code index; +- separate DOM alias index that rejects collisions; +- active render reservation map plus consumed/stale tombstones; +- request-intent and active-cycle state per slot. + +`MAX_ACTIVE_SLOT_RECORDS` is 256 across server-projected and programmatically +registered records in one `NavigationSession`. The immutable server projection is +validated against that total before the kernel commits; an oversized projection is +an `abi_mismatch` boot failure. `addAdUnits` reserves capacity for its whole input +before mutation and rejects the whole call with +`AdUnitRegistrationError{code:'registry_capacity'}` when the remaining capacity is +insufficient. Navigation disposal synchronously removes every record and secondary +index owned by that navigation; reservations/tombstones retain only their separate +bounded lifecycle below. + +Registration rejects a missing, empty, or greater-than-256-UTF-8-byte server or +programmatic slot id before indexing and assigns each valid slot a monotonically +increasing, navigation-local ordinal. An exact registered-slot-id collision is +rejected rather than overwritten. GPT ad-unit-code and DOM-alias indexes retain +collision state: a lookup must resolve exactly one record, and zero or multiple +matches fail `slot_unresolved`; registration order is never used to choose among +collisions. + +The reservation map and tombstones share a capacity of 320. An SSAT/page-bid render +reservation has a fixed 15-minute lifetime measured by the runtime's monotonic clock +from browser registration; consumption does not extend it. A Prebid bid awaiting +client-side selection instead receives a ten-second admission lease. Exact selection +atomically promotes that lease to a render reservation with a new fixed 15-minute +lifetime measured from promotion; this occurs before targeting can expose the id and +is the only expiry replacement. An admission lease is suppress-only and cannot +satisfy a PUC claim; a request carrying that id before selection is refused, +tombstones the lease, and records `prebid_contract_violation`. Unselected, aborted, +or selection-timed-out entries +become tombstones only through their original ten-second lease expiry. Expired +entries are pruned. +Unexpired entries are never evicted because eviction would allow a late creative +request to escape TS ownership. At capacity, registration fails with +`registry_full`. A consumed, stale, or disposed reservation remains a tombstone +until its original expiry and never produces a second response. While live, each +admission lease or render reservation records its exact slot, render source, +immutable `WinnerContext{selectedCpm}`, navigation generation, expiry, and state. +`selectedCpm` is copied from the fully validated selected projected bid and is +finite and nonnegative. Prebid admission verifies that the frozen bid's `cpm` is +exactly this stored value, and selection promotion preserves the same context +rather than reconstructing it from Prebid. A successful PUC claim transfers the +context into the `RenderAttempt` before replacing the live entry with a tombstone. +The tombstone discards the render source and winner context and retains only the id, +original expiry, terminal state, and minimum suppression metadata. Neither the +descriptor nor any capability contains CPM. + +Ids are unique across all live/tombstoned entries, so lookup identifies one entry +and then requires its exact active slot, cycle, and generation. The first compatible +PUC claim acquires its source and winner context; a live or tombstoned TS id is +suppressed before detailed validation. + +Direct `/auction` rendering does not round-trip through a PUC reservation. Its exact +winner join creates the `RenderAttempt` with an immutable +`WinnerContext{selectedCpm}` copied from that same validated projected winner before +any rendering or cache fetch. Thus direct and PUC paths have the same CPM authority +without inventing a bridge capability for direct rendering. + +The current `__tsRenderGeneration`, `__tsRenderBid`, and function-sentinel +expandos are removed. + +### 2.4 GPT request-cycle ownership + +GPT events describe physical requests and are not promises. The runtime therefore +tracks request intent separately from physical cycles: + +1. A TS operation records an intent before calling `display()` or `refresh()`. +2. A TS operation never treats `display()` as request-capable while initial load is + disabled. It uses `display()` only to register the slot, then invokes exactly one + `refresh([slot], {changeCorrelator:false})`; the request intent and three-second + start deadline attach only to that refresh. If the adapter cannot invoke refresh + or the call throws, the attempt fails `gpt_request_failed`. A publisher-owned + `display()` remains publisher activity and starts no TS attempt or fallback. +3. A physical cycle opens only on `slotRequested` and closes on the corresponding + `slotRenderEnded`. +4. One TS-owned cycle may be outstanding per slot, with at most one queued + replacement. +5. Attribution requires exactly one live compatible intent. Overlap or a later + request-capable intent that makes ownership ambiguous fails the affected TS + cycle with `cycle_unattributable`; it is never guessed from timing. +6. `responseIdentifier` deduplicates completion but is not an ownership token. +7. A cycle is re-armed only by counted completion or safe TS-owned + destroy/redefine—never by a timeout or navigation disposal that merely hopes GPT + has finished. + +`slotRequested` and `slotRenderEnded` listeners are installed unconditionally +before any TS display/refresh call. SRA opens one logical cycle per participating +slot. Publisher-initiated GPT activity remains publisher-owned and cannot trigger +TS fallback. + +An operation waiting for GPT or Prebid readiness has its own fixed ten-second +deadline measured from enqueue and fails `external_ready_timeout`; public +`requestAds.timeoutMs` never shortens or extends it. After a request-capable +`display()` or `refresh()` is +invoked, `slotRequested` must arrive within three seconds or the attempt fails +`gpt_request_timeout`. Once `slotRequested` arrives, its matching +`slotRenderEnded` must arrive within ten seconds or the attempt fails +`gpt_completion_timeout`. A timeout tombstones the reservation, closes owned ports, +and settles the attempt, but does not pretend the physical GPT cycle completed. + +At `gpt_request_timeout`, no attributable physical cycle exists. The adapter +immediately invokes the transactional TS-owned destroy/redefine contract in §5.7 +and permanently retires the old object. Failure defines no replacement and leaves +the path quarantined; later TS work fails `gpt_request_failed`. A publisher-owned +object enters page-lifetime quarantine and cannot +accept new TS work until the publisher explicitly destroys that object or the page +reloads; no later `slotRequested` or `slotRenderEnded` may release that quarantine +because it cannot be attributed to the timed-out invocation. At +`gpt_completion_timeout`, the already-open exact physical cycle stays retired or +quarantined until its matching real completion, safe TS-owned destroy/redefine, +publisher destruction, or reload. Late GPT events only drain an already attributable +completion-timeout cycle; they cannot revive an attempt, start fallback, or re-arm a +request-timeout quarantine. + +Navigation disposal does not manufacture a GPT completion. If the open slot is +TS-owned, the adapter invokes the §5.7 transaction; a replacement is defined only +when the current navigation still needs it and exact destruction succeeded. The +retired object remains in the runtime `WeakMap` until its late completion drains and +can never be matched to a replacement. Destroy failure leaves no second object and +quarantines later TS work. If it is publisher-owned, the physical cycle +is quarantined: new TS work for that GPT object fails `slot_quarantined` until the +matching `slotRenderEnded`, publisher destruction, or full page reload. There is no +timeout-based re-arm. In particular, an old completion after navigation but before +the replacement's completion settles only the retired/quarantined cycle. + +### 2.5 SPA and concurrent work + +- `RuntimeSession` survives SPA navigations and owns the global lifetime plus + injected adapter/service disposers. Runtime-scoped slot and reservation services + own the slot-object map, bridge listener, reservations/tombstones, and physical + GPT cycle state; kernel code knows them only through interfaces. +- `NavigationSession` owns route-specific slot aliases, request intents, auction + batches, attempts, timers, targeting history, and one internal immutable current + auction-projection snapshot. +- A new navigation atomically replaces the prior `NavigationSession`; disposal + cancels its live attempts and prevents late callbacks from mutating the new one. +- The initial session seeds its internal projection from the recursively frozen + `tsjs.boot.auctionProjection`. A later SPA session begins with no current + projection. Its page-bids controller accepts only one exact, fully validated + `BrowserAuctionProjectionV1` for the current navigation generation, deep-copies + and freezes it, transactionally registers all projected slots against the shared + 256-slot cap, then commits it to the session. A stale, duplicate, malformed, or + over-cap response commits no slot, targeting, bid, or projection and cannot retain + the prior navigation's data. Programmatic registrations admitted before that + response count against the same transaction. The immutable public boot object is + document-generation input and is never rewritten into a mutable current-state + carrier. +- An `AuctionBatch` owns one `/auction` fetch and one child attempt per requested + slot. Supersession cancels children individually. The shared fetch is aborted + only when every child is terminal, the caller aborts all children, the batch + response deadline expires, or navigation disposes. +- After a parsed response is processed, every still-live child receives the exact + server decision for its slot; missing, duplicate, or inconsistent decisions are + `invalid_response`, never inferred as `no_bid`. + +### 2.6 Fallback + +`SlotOperation` owns the public per-slot result and one primary `RenderAttempt`. +Fallback is opt-in and, when eligible, becomes a second child attempt. It may start +only after an attributable TS-owned GAM cycle terminates empty. Publisher-owned, +ambiguous, timed-out, quarantined, or stale cycles never trigger fallback. Each +child has its own immutable terminal result and local history. The operation +settles once: with the primary result when no fallback runs, or with the fallback +child result and `path:'fallback'` after the primary `gam_empty`. A child never +overwrites its parent or sibling. + +## 3. APS wire and server contracts + +### 3.1 One descriptor + +The only APS render descriptor is: + +```ts +interface ApsRendererV1 { + type: 'aps' + version: 1 + accountId: string + bidId: string + creativeId?: string + tagType: 'iframe' | 'script' + creativeUrl: string + width: number + height: number + aaxResponse: string +} + +interface AdmRenderSourceV1 { + type: 'adm' + version: 1 + adm: string + width: number + height: number +} + +interface CacheRenderSourceV1 { + type: 'cache' + version: 1 + cacheId: string + fetchUrl: string + width: number + height: number +} + +type BidRenderSourceV1 = ApsRendererV1 | AdmRenderSourceV1 | CacheRenderSourceV1 + +interface CacheFetchPolicyV1 { + version: 1 + baseUrl: string +} ``` -| `t` | fields | allowed `flow` | -| -------------------------- | --------------------------------------------- | ----------------------- | -| `request_cycle_started` | slot | ssat, prebid, page_bids | -| `bid_received` | slot, id_kind, source | render flows | -| `targeting_set` | slot, id_kind | render flows | -| `attempt_started` | slot, source? | render flows | -| `bridge_request` | slot, id_kind, matched | ssat, prebid, page_bids | -| `bridge_response_sent` | slot, source | ssat, prebid, page_bids | -| `render_terminal` | slot, outcome, reason?, source? | render flows | -| `gam_nonempty` | slot | ssat, prebid, page_bids | -| `gam_empty` | slot | ssat, prebid, page_bids | -| `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? | ssat, prebid, page_bids | -| `renderer_document_loaded` | slot | render flows | -| `runner_loaded` | slot | render flows | -| `runner_failed` | slot, reason | render flows | -| `adm_document_loaded` | slot | render flows | -| `fallback_start` | slot | fallback | -| `notification_sent` | slot, kind (`nurl`\|`burl`), notif_id, result | render flows | -| `client_queue_overflow` | dropped (count) | system | -| `heartbeat` | probe_run_id, expected_seq, adapter, target | system | - -Render flows = `ssat | prebid | page_bids | direct | fallback`. -`attempt_started` carries the attempt's `t_rel_ms` baseline and its -`source` is **optional** (direct attempts start before a response selects -a source); the latency metric is `render_terminal{accepted}.t_rel_ms − -attempt_started.t_rel_ms` per attempt. **`render_terminal.source` is -present iff a render source was actually bound** — absent for -`no_bid`/`cancelled` and every pre-winner outcome (`auction_timeout`, -`network_error`, `http_error`, `invalid_response`) as well as the -pre-source reasons (`gpt_absent`, `pbjs_absent`, `slot_unresolved`, -`intent_no_request`, `abi_mismatch`, `registry_full`, `bundle_partial`); -the generated per-event/per-reason validity matrix (§6.7) encodes -source-presence by whether a source was bound, not by a hand-list. -Reason enum: `renderer_document_no_load`, -`runner_no_load`, `runner_failed`, `descriptor_invalid`, -`invalid_dimensions`, `dimensions_out_of_range`, `bridge_id_mismatch`, -`cycle_unattributable`, `intent_no_request`, `stale_navigation`, -`bridge_claim_timeout`, `gam_empty`, `no_render_source`, -`slot_unresolved`, `gpt_absent`, `pbjs_absent`, `bundle_partial`, -`fallback_cancelled`, `abi_mismatch`, `registry_full`, -`currency_mismatch`, `auction_timeout`, `network_error`, `http_error`, -`invalid_response`, `adm_document_no_load`. - -### 5.2 Transport, batching, and budgets (limiter-consistent) - -- Batches are capped by **both** 64 events **and** 12 KiB encoded - payload (headroom under the 16 KiB ingest cap); a flush drains the - queue as up to **4 sequential batches**. -- Cadence: flush every **10 s** and on `visibilitychange`/`pagehide`. - Worst-case honest traffic per tab: 6 flushes/min × ≤ 4 batches = ≤ 24 - requests/min transient, typically ≤ 6. -- **Budgets derived from that worst case:** client-side trace budget - ≤ 8 batches/min sustained (excess coalesces into the next flush); - ingest per-address budget **60 req/min, burst 120** (≈ 5 active tabs - plus pagehide bursts). The limiter can no longer reject honest - steady-state traffic by construction; tests cover sustained - single-tab, multi-tab (5), diagnostic pre-upgrade buffer flush, and - pagehide bursts. -- Transport: `fetch(..., {keepalive: true, credentials: -"same-origin"})`; `pagehide` fallback `sendBeacon(url, new -Blob([json], {type: "application/json"}))`. Queue bound 256; overflow - uses the out-of-band saturating counter + one coalesced - `client_queue_overflow` in the next flush (never enqueued into a full - queue). - -### 5.3 Signed authorizations - -**Trace authorization** `v1...[.].` (`auth` -ingest bound 256 bytes; all other strings 64): - -- `kid ^[a-z0-9-]{1,16}$`; keys ≥ 256-bit CSPRNG in the secret store; - previous keys retained ≥ 24 h; missing key with the feature enabled → - loud first-use failure. -- `exp` canonical decimal; ±60 s skew; ≤ 15 min future. `mode`: - `sampled | unsampled | diagnostic | probe`. **`dexp` is present iff - `mode = diagnostic`** — the immutable diagnostic ceiling, signed into - the token, set at upgrade to the credential's absolute expiry. - **Every renewal of a diagnostic token re-derives - `exp = min(now + 15 min, dexp)` and preserves `dexp`; past `dexp`, - renewal fails** — diagnostic access is bounded by the credential - forever, not just at upgrade (closing the indefinite-renewal hole). - Tests: renewal-before-expiry capped, repeated renewal to the ceiling. -- `sig` = unpadded base64url HMAC-SHA-256 over - `"ts-trace-auth-v1" || u32be(len(origin)) || origin || -u32be(len(trace_id)) || trace_id || u32be(len(mode)) || mode || -u64be(exp) [|| u64be(dexp)]`; constant-time compare; per-group - rejection at ingest. `unsampled` transmits nothing and is rejected if - carried. **Probe issuance protocol:** the probe runner authenticates - to `POST /_ts/admin/probe-authorization` (admin auth + CSRF) and - receives a batch of pre-signed probe-mode tokens tagged - `probe_run_id`; probe traffic is never sampled out and excluded from - product metrics by mode. -- Renewal: `GET /_ts/trace-auth` presenting the current still-valid - token in `X-TSJS-Trace-Auth`; re-signs same trace + mode (+ `dexp`). - **Diagnostic upgrade** is the sole mode transition: - `POST /_ts/trace-auth/upgrade` presenting token + credential. - -**Diagnostic credential** `d1....` — full byte-level -spec with vectors: `oh` = first 16 hex chars of SHA-256 of the -externally visible origin (scheme+host+port, UTF-8); `sig` = unpadded -base64url HMAC-SHA-256 over `"ts-diag-cred-v1" || u32be(len(origin)) || -origin || u64be(exp)`; same kid charset, key strength, rotation, and -≥ 24 h previous-key retention; ±60 s skew; absolute expiry ≤ 60 min; -constant-time compare; replayable short-lived bearer by design (bounded -by expiry + origin). Issued `POST /_ts/admin/diagnostic-credential` -(admin auth, CSRF: same-origin + custom header). Transport: `#tsdiag=` -fragment → read synchronously, cleared via `history.replaceState`, held -in memory only (RuntimeSession); pre-upgrade events buffer locally -(bounded 256) and flush after upgrade. Forgery, wrong-origin, -replay-past-expiry tests. - -Lazy cached initialization applies to every secret-backed component; -failure with the feature enabled is that feature's loud error path. - -### 5.4 Ingest and rate limiting - -- `POST /_ts/client-events`: `application/json` only; no - `Content-Encoding`; `204`, `no-store`; never echoes input. Pre-parse: - body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 (`auth` ≤ 256 B); - `trace_id ^[0-9a-f]{32}$`; `attempt_id ^[a-z0-9]{8}$`; `auction_id` - canonical UUID; integers `[0, 2³¹)`; `t_rel_ms` u32. -- Same-origin (client-events, trace-auth): `Sec-Fetch-Site: -same-origin` else normalized `Origin` equality; absent both → - drop-and-count. -- Limiter (per §5.2 budgets): trait `ClientEventLimiter`; Axum real - token bucket (60/min, burst 120), map ≤ 65,536; Cloudflare/Spin - best-effort ≤ 4,096/instance; TTL 10 min, cleanup on access + sweep; - at capacity reject unseen identities (expired always reclaimable); - unknown address → shared bucket 6/min; Fastly platform 60 s window at - limit 120 (approximation; overshoot bounded only by in-flight - concurrency — documented by the synchronized-burst test, no numeric - multiple claimed). Limiter unavailable → drop early with `204`. - Trusted address per adapter (Fastly platform IP; Axum rightmost XFF - after `trusted_proxy_hops`, absent → socket peer; CF - `CF-Connecting-IP`; Spin platform). Trace-auth and CSP routes carry - their own buckets (10/min, burst 20). - -### 5.5 Sinks, canonical views, per-sink authenticated probes - -- Event key `(publisher_domain, trace_id, seq)`; canonical views - `ts_client_events_v` (dedup) and `ts_render_attempts_v` (**keyed by - `attempt_id`**); the arm-union views stamp `deployment_pool` from the - write identity (§0). Dashboards/alerts query canonical views only. -- The Fastly sink is fire-and-forget (`tinybird.rs:153`) — - **per-datasource authenticated probes**. **Every probe-capable table - carries `{probe_run_id, expected_seq, adapter, target}`** (added to - `ts_csp_reports` and `ts_ops_counters` below, not only - `ts_client_events`), so loss queries distinguish runs, retries, resets, - and adapters. **These four fields are cryptographically bound to the - issued probe authorization** — the admin-issued probe token - (`POST /_ts/admin/probe-authorization`, §5.3) signs `probe_run_id`, - `adapter`, and `target`, and the ingest handler stamps the row from the - verified token rather than trusting submitted values. A secret-derived - CSP probe `policy_id` is a bearer capability for _routing_ only; it is - never treated as proof of authentic run metadata. **Persistence gates - are scoped to sink-backed adapters** (DR-5); accept-count-drop adapters - get HTTP-parity gates only. Freshness = probe lag ≤ 5 min; loss = - `expected_seq` gaps < 0.1%; alert owner: release owner's on-call. -- **Alert-delivery drill:** a synthetic canary page injects a known - failure class at a known rate; the failure-detection alert must fire - within one hour — dashboards and alert latency are tested, not - assumed (Appendix A row). - -### 5.6 Physical schemas (deployed before writers) - -- **`ts_client_events`**: `received_at DateTime64, publisher_domain -LowCardinality(String), release_id String, deployment_pool -Enum(canary|control), assignment_id Nullable(FixedString(32)), -trace_id FixedString(32), mode Enum(sampled|diagnostic|probe), nav_gen -UInt32, refresh_gen UInt32, seq UInt32, flow -Enum(ssat|prebid|page_bids|direct|fallback|system), attempt_id -Nullable(FixedString(8)), parent_attempt_id Nullable(FixedString(8)), -auction_id Nullable(UUID), t_rel_ms Nullable(UInt32), event Enum(§5.1), -slot Nullable(String), id_kind Nullable(Enum), matched Nullable(UInt8), -source Nullable(Enum), reason Nullable(Enum), outcome -Nullable(Enum(accepted|failed|no_bid|cancelled)), action -Nullable(Enum), kind Nullable(Enum), notif_id Nullable(FixedString(12)), -result Nullable(Enum), dropped Nullable(UInt32), probe_run_id -Nullable(String), expected_seq Nullable(UInt32), adapter -Nullable(Enum), target Nullable(Enum)`. Sorting key `(publisher_domain, -received_at, trace_id, seq)`; TTL 30 days; sink batch cap 512; startup - validation; sink-unavailable → accept-count-drop. -- **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`): - add nullable `trace_id`, `mode`, `release_id`; - `row_kind Enum(slot|totals|overflow)`; `bid_drop {row_kind, provider -Nullable(LowCardinality(String)), slot Nullable(String), reason -Enum(AuctionDropReason), width Nullable(UInt16), height -Nullable(UInt16), count UInt32}` (32 slot-rows + one overflow row whose - `count` = actual dropped bids); `selection_summary {row_kind, slot -Nullable(String), winner_source Nullable(Enum(mediator|direct|none)), -winner_provider Nullable(String), candidates_direct UInt16, -candidates_mediator UInt16, dedup_hits UInt16, currency_rejected -UInt16, provenance_invalid UInt16, mediator_superseded UInt16}` (8 - slot-rows + one totals row that survives truncation; saturating - counters `0xFFFF`/`0xFFFFFFFF`). - - **`AuctionDropReason` (closed, exhaustive over baseline producers, - one shared typed enum — no string literals):** - `script_rendering_disabled, invalid_dimensions, -dimensions_out_of_range, missing_render_source, invalid_creative_url, -unsupported_tagtype, render_payload_too_large, -unexpected_response_shape, currency_mismatch, floor_rejected, -provenance_invalid, duplicate_demand, missing_bid_id, -duplicate_bid_id, bid_id_too_large, empty_seatbid, -empty_seatbid_bids, unknown_impid, invalid_price, -unsupported_media_type, creative_id_too_large, -renderer_extension_serialization_failed, no_render_source, -lost_to_higher_bid, unsupported_currency, missing_request_context, -overflow` — covering every baseline producer at `aps.rs:740-929` - (incl. `empty_seatbid_bids` at `:875`, `unsupported_currency`, and - the response-level `missing_request_context`, whose totals/error-row - disposition is `row_kind = totals` with a null slot) and - `formats.rs:408-419`. `currency_mismatch` and `unsupported_currency` - are **distinct** (config-vs-response mismatch vs an unsupported - currency code). Producers emit a **shared typed enum, no string - literals**, so the **compile-time exhaustiveness test is real**: every - variant maps to a producer and every producer to a variant. -- **`ts_csp_reports`**: `received_at DateTime64, publisher_domain -LowCardinality(String), release_id String, policy_id -LowCardinality(String), cohort LowCardinality(String), directive_bucket -Enum(script|style|frame|img|connect|font|media|worker|other), -source_bucket Enum(https_host_allowlisted|data|blob|inline|eval|other), -count UInt32, probe_run_id Nullable(String), expected_seq -Nullable(UInt32), adapter Nullable(Enum), target Nullable(Enum)`; - sorting key `(publisher_domain, received_at, policy_id)`; TTL 30 days. - Ingest: body ≤ 8 KiB, ≤ 10 reports/request, strings ≤ 256, nesting ≤ 4, - both media types with separate validators, unused fields discarded, own - limiter bucket. **Caps with values:** 10,000 reports/hour/publisher and - 1,000/hour/cohort; overflow increments the `csp_overflow` ops counter - (dropped reports counted, never parsed further). -- **`ts_ops_counters`**: `received_at DateTime64, publisher_domain -LowCardinality(String), release_id String, counter -Enum(renderer_requests|renderer_unknown_version|renderer_auth_blocked| -ingest_accepted|ingest_dropped|ingest_rate_limited|abuse_flagged| -csp_overflow|probe), value UInt64, probe_run_id Nullable(String), -expected_seq Nullable(UInt32), adapter Nullable(Enum), target -Nullable(Enum)`; sorting key `(publisher_domain, received_at, counter)`; - TTL 90 days. -- **Sink plumbing:** one generic multi-target Tinybird sink trait; - `RuntimeServices` (`platform/types.rs:158`) gains handles for - client-events, CSP, and ops targets beside the auction sink; each - target has its own dataset + token settings. -- **Settings:** `[telemetry.client_events] collection_enabled, -sink_enabled, sample_rate, api_host, dataset, token_secret, -secret_store, max_body_bytes`; `[telemetry.csp_reports]` and - `[telemetry.ops_counters]` (same transport shape); - `[telemetry.trace_auth] secret_store, active_kid, previous_kids, -sampling_key_secret`; `[telemetry.diagnostic] secret_store, -active_kid`; `[telemetry.probe]` admin-issued run configuration. -- APS parsing returns structured drop observations - `{reason, slot, width?, height?}` (`aps.rs:722` loses slot/values); - > 8192 → `dimensions_out_of_range` unclamped. - -### 5.7 Modes and SLIs - -Production (sink-backed): deterministic sampling (0.10). SLIs: pipeline -availability (per-sink authenticated probe freshness ≤ 5 min, loss -< 0.1%); failure detection (≥ 1% of sampled render attempts visible -within one hour at ≥ 10,000/hour) — **verified by the injected-failure -alert drill**, not only by probe persistence. Diagnostic: -credential-gated, `dexp`-bounded, unsampled, full stream + console -mirroring + debug envelopes. - -### 5.8 Server-side drop surfacing - -Bounded structured summary whenever any bid is dropped; `bid_drop` + -`selection_summary` rows; `ts-debug` comment; tester-gated structured -`debug` on page-bids and `/auction`. Startup warnings: APS + -`allow_script_creatives = false`; mediator + direct providers without -explicit `winner_selection`. - -## 6. APS delivery fixes - -### 6.1 Mediation - -Seven rules, arrival-independent, one shared helper across both -lifecycles: (1) required `[auction].currency` (APS + non-USD = startup -error; Prebid validates at parse, `prebid.rs:2433`); (2) candidate -identity `source_candidate_id = (provider_name, upstream_bid_id)` with -the upstream id **required, ≤ 64 chars, unique** — missing/duplicate/ -oversized → `bid_drop{missing_bid_id | duplicate_bid_id | -bid_id_too_large}`, **no fingerprint fallback**; `candidate_id` is a -server-minted 12-char `^[a-z0-9]{12}$` CSPRNG wire echo with in-auction -collision retry, **never an ordering key**, and the mediator's echoed -value is validated against the issued set on return (an echo matching no -issued id is `provenance_invalid`); (3) mediator echoes -`ext.trusted_server.candidate_id` — resolves → forwarded candidate, -provenance `mediator`, **price authoritative from the mediator, every -render-source and notification field from the stored candidate, deal -fields out of scope**; any render-source difference → mediator-native; -unresolvable echo → discarded + counted (`provenance_invalid`), -mediator-native and direct candidates stay eligible; (4) floors both -populations, echoes dedup twins; (5) **total order** decoded CPM desc → -provenance rank (mediator first) → `source_candidate_id` asc; (6) -required `winner_selection` (`mediator_only`: timeout → no winners -unless `mediator_timeout_fallback = "direct"`; `merge_highest_cpm`: -timeout → direct-only); (7) `selection_summary` reporting. - -### 6.2 Dimensions - -Exact membership (`aps.rs:675`); structured `bid_drop{invalid_dimensions, -w, h}`; "request the sizes you accept." - -### 6.3 Script creatives - -Default `false`, loud (§5.8); **DR-2 is a deployment decision** (enable -with security approval, or accept a quantified excluded share and gate -Phase 3 on it). - -### 6.4 / 6.5 - -Render identity per G2; fallback per G4e (child attempt). - -### 6.6 Renderer endpoint - -Unconditional route in every adapter (provider stays config-gated); -startup auth-pattern validation; `/integrations/aps/renderer/v1` -embedded, served `public, max-age=31536000, immutable` with a -checked-in per-version header manifest (headers frozen with bytes); -canary versions `no-store`; unknown version 404 `no-store`; -three-message ack (G4b-1); aggregate route counters in -`ts_ops_counters`; CSP rollout three instruments (enforced discovery / -report-only tightening / enforced-cohort relaxation on a short-lived -canary version, gated on runner acceptance, violation rate, render -failure, with a kill switch); **policy identity in the server-selected -`policy_id` path** (also the release/cohort carrier, §0); bucketed -aggregation only with the §5.6 caps; never a sole rollback signal; -three-browser capture (`playwright.config.ts:16`). - -### 6.7 One descriptor schema - -Tagged `BidRenderer` envelope (`types.rs:188-211`); wire-schema -crate/xtask (no core↔js cycle, `Cargo.toml:45`) generates JSON-Schema, -TS parser, ES5 inline fragment, the §5.1 validity matrix, and fixtures; -staleness CI; semantic validators handwritten; outer tolerance only; -exact AAX projection; shared positive + adversarial corpus through all -three validators. - -### 6.8 Bridge hardening - -Order (normative — preserving the baseline stolen-capability defense, -`gpt/index.ts:1584-1637`, **plus the read-only lookup the altered-id -signal needs**): - -1. parse `e.data` (bare catch → return); -2. **read-only source→active-slot lookup for every Prebid Request** — if - the resolved slot expects a different ad id, emit - `bridge_request{matched: false}` (the B1 signal) **without responding - and without suppressing native Prebid** (a truncated `hb_adid` is not - TS-reserved, so suppression would be wrong and the old order could - never produce the signal); -3. identify a TS-reserved ad id (live registry or tombstone, G2); -4. if TS-reserved: `stopImmediatePropagation()` before validation; -5. validate source ownership (known slot-root `WindowProxy` map; - sender's parent chain to depth 5; never scanning the frame tree); -6. validate nonce, token, `nav_gen`, `refresh_gen` (G4b); -7. respond, or refuse with `bridge_id_mismatch`. - -Non-TS ids are otherwise untouched. Stolen-token test: neither TS nor -native Prebid responds. Listener order: real-browser assertion. - -## 7. TSJS target architecture - -### 7.1 Layering +`BidRenderSourceV1` is the only browser render-source union. A selected internal +Rust `Bid` carries exactly one corresponding enum member; separate optional +creative, renderer, and cache fields are removed. APS markup is not smuggled +through `adm`, `meta`, or debug fields. Each tagged object rejects unknown keys. +Limits are defined once and shared by the Rust producer, TypeScript parser, and +embedded renderer validator: + +- nonempty `accountId` and optional nonempty `creativeId`, each at most 1,024 + UTF-8 bytes; +- nonempty `bidId` of at most 64 UTF-8 bytes; +- numeric, finite, integral `width` and `height`, each in the inclusive shared + `RENDER_DIMENSION_MIN = 1` through `RENDER_DIMENSION_MAX = 4096` CSS-pixel range; +- `creativeUrl` at most 4,096 UTF-8 bytes, HTTPS, no credentials, and not the + publisher origin; +- canonical standard-base64 `aaxResponse`, decoded size at most 256 KiB; +- exactly one decoded seat and one decoded bid; +- decoded bid id, dimensions, `creativeurl`, and `tagtype` exactly match the + duplicated descriptor fields; +- finite, nonnegative decoded price. + +A checked-in schema/corpus is the cross-language conformance source. Rust, +TypeScript, and the ES5 renderer validator run the same positive and adversarial +vectors. CI fails when generated ES5/schema output is stale. Semantic validation +that cannot be represented in JSON Schema remains in small handwritten validators +covered by the same corpus. + +For `adm`, markup is nonempty and at most 512 KiB. For `cache`, `cacheId` is the +exact validated PBS Cache UUID. When cache rendering is enabled, the server emits +one trusted `CacheFetchPolicyV1` at `tsjs.boot.cachePolicy` before core; `baseUrl` is +the same immutable configuration snapshot used for auction projection and is an +absolute HTTPS URL of at most 4,096 UTF-8 bytes with a host and fixed nonempty path +but no credentials, query, or fragment. Core validates and freezes it before any +integration module prepares. A cache source without a valid boot policy is `descriptor_invalid` +and is never fetched. + +`fetchUrl` is constructed server-side from that exact base URL, is at most 4,096 +UTF-8 bytes, and has exactly one query parameter, +`uuid=`; it has no credentials, fragment, or other query +parameter. The browser parses both values and requires exact origin, +port, and pathname equality with the frozen base, empty username/password/hash, +exactly one `uuid`, decoded UUID equality with `cacheId`, and canonical search text +`?uuid=${encodeURIComponent(cacheId)}`. It then fetches with `redirect:'error'`, +`credentials:'omit'`, and `referrerPolicy:'no-referrer'`, enforces CORS, a +five-second deadline, a 512 KiB body limit, successful HTTP status, and a JSON +object response. The response requires an own, nonempty `adm` string of at most +512 KiB. Optional `w` and `h` must occur together as integral numbers within the +same 1–4096 range and must equal the source dimensions. Optional `price` must be a +finite nonnegative number but is never rendering authority. Other OpenRTB bid keys +are allowed and ignored; raw markup bodies, arrays/primitives, `width`/`height` +aliases, accessors, and alternate wrapper shapes are rejected. The response need +not echo `cacheId` because the exact request URL is its transport binding. +`${AUCTION_PRICE}` in the ADM is expanded only as +`String(attempt.winnerContext.selectedCpm)` from the context transferred by the +consumed reservation or installed by exact direct-winner admission, never from the +cached response's `price`, current projection, targeting, or a later winner. Only +the exact token is replaced; +`${AUCTION_PRICE:B64}` remains untouched. The resulting ADM enters the direct ADM +lifecycle. +Transport, status, or shape failure is respectively `cache_network_error`, +`cache_http_error`, or `cache_invalid_response`; none becomes `no_bid`. + +### 3.2 Per-slot auction decisions + +Every server auction entry point produces exactly one decision for every requested +slot, in request order: + +```ts +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' + +type SlotAuctionDecisionV1 = + | { slot: string; outcome: 'winner'; candidateId: string } + | { slot: string; outcome: 'no_bid' } + | { slot: string; outcome: 'failed'; reason: AuctionSlotFailureReason } + +interface AuctionDecisionSetV1 { + version: 1 + auctionId: string + results: SlotAuctionDecisionV1[] +} + +interface BrowserAuctionProjectionV1 { + version: 1 + auction: AuctionDecisionSetV1 + bids: Array<{ + candidateId: string + slot: string + provider: string + upstreamBidId: string + cpm: number + currency: 'USD' + targeting: Record + rendererReservationId: string + renderSource: BidRenderSourceV1 + }> +} +``` +`BrowserAuctionProjectionV1` is exact, deny-unknown, and bounded before any slot, +reservation, targeting, or bid mutation. Its canonical UTF-8 JSON is at most +`MAX_BROWSER_AUCTION_PROJECTION_BYTES = 8 * 1024 * 1024`; `auction.results` and +`bids` each contain at most 256 entries; and all objects are plain own-data objects +with no accessors. Canonical serialization uses the interface field order shown, +request order for results, matching result order for bids, lexically sorted targeting +keys, and no insignificant whitespace. `auctionId` matches +`^[A-Za-z0-9._:-]{1,128}$`; candidate ids use +the exact 12-character base64url form from §3.4 and are unique; result slots are +unique, follow the §2.2 bound, and contain no NUL or ASCII control; every winner has +exactly one bid with the same slot/candidate and non-winners have none; +`rendererReservationId` uses the exact unique `r1_` form from §2.2; provider matches +`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`; and upstream bid ids are 1–64 UTF-8 bytes with +no NUL or ASCII control. CPM is a finite nonnegative number and currency is exactly +`USD`. + +Each bid's `targeting` member is a plain own-data object with at most 32 entries. A key +matches `^[A-Za-z0-9_]{1,20}$`, is unique and case-sensitive, and cannot be +`hb_adid`, which the runtime alone synthesizes from the reservation. A value is +nonempty, contains at most 40 Unicode scalar values and 160 UTF-8 bytes, and contains +no NUL or ASCII control. The producer applies the same rules. A winning candidate +that cannot be projected becomes `winner_not_renderable`. + +Aggregate overflow has one exact server outcome. The producer first constructs and +measures the complete canonical projection. If it exceeds 8 MiB, it transactionally +converts every `winner` result in that auction to +`failed{reason:'winner_not_renderable'}`, emits no projected winner bids, and retains +the existing no-bid/failed results in request order. For `/auction`, the corresponding +TS winner bids are likewise absent from `seatbid`; no unmatched decision or bid is +emitted. The reduced projection is guaranteed to fit from the 256-result/id bounds. +Initial HTML, page-bids, and direct response production use this same all-winners +rule, never a completion-order or first-fit subset. Boot rejects any independently +malformed or oversized value as `abi_mismatch`; page-bids and direct response +admission reject it transactionally as `invalid_response` with no partial slot, +reservation, targeting, or bid state. + +Wrong type, nonfinite, fractional, zero, or negative render dimensions are +`invalid_dimensions`; an otherwise integral dimension outside 1–4096 is +`dimensions_out_of_range`. This exact distinction and range apply in the Rust +producer, TypeScript projection/source parser, ES5 APS renderer validator, +programmatic banner sizes, cache `w`/`h`, PUC/renderer DOM construction, and every +CSS/attribute layout assertion. No adapter may clamp an accepted dimension. + +Each provider response is normalized internally to exactly one +`ProviderSlotOutcome`—candidate, no-bid, or typed failure—for every slot dispatched +to that provider. A successful response that omits a dispatched slot is a provider +no-bid; launch, transport, timeout, HTTP, parsing, validation, and mediation errors +are failures for the affected dispatched slots. Final aggregation is deterministic: + +1. Pre-dispatch gating returns `auction_disabled`, `consent_denied`, or + `slot_not_eligible` directly. A slot with zero eligible providers is exactly + `failed{reason:'slot_not_eligible'}`, never a no-bid. Provider currency rejection + is `invalid_provider_response`; there is no separate render-layer currency reason. +2. A selected deliverable candidate is `winner`, even if another provider failed. +3. Failure to mint a unique renderer reservation for the selected candidate is + `failed{reason:'identity_generation_failed'}`. Any other failure to validate or + project the selected candidate is `failed{reason:'winner_not_renderable'}`. +4. With no winner, the slot is `no_bid` only when at least one provider was + dispatched and every eligible/dispatched provider + completed successfully with no candidate. +5. With no winner and any provider or mediation failure, the slot is `failed` with + the first applicable reason in this closed priority order: `internal_error`, + `mediation_failed`, `invalid_provider_response`, `provider_error`, + `provider_timeout`, `consent_denied`, `auction_disabled`, `slot_not_eligible`. + `winner_not_renderable` and `identity_generation_failed` are selected directly by + rule 3 and do not participate in multi-provider priority. Completion order is + irrelevant. + +`/auction` keeps ordinary OpenRTB winners in `seatbid` and places the decision set +at `ext.trusted_server.slot_results`. Every TS winner bid has this exact nested +extension; unknown keys inside `trusted_server` are invalid: + +```ts +interface TrustedServerOpenRtbBidExtV1 { + candidate_id: string + slot_id: string + render_source: BidRenderSourceV1 +} ``` -kernel/ boot, config, queue, event bus, log, beacon, sessions -adapters/ googletag.ts, pbjs.ts, messaging.ts ← only access to external ad-tech globals -services/ slots (registry+handoff), auction client, render engine, consent -integrations/ gpt, prebid, aps, creative, datadome, … + +The bid's standard `id` is the server-minted renderer reservation id from §2.2; +the provider's upstream id remains provenance and, for APS, the descriptor's +`bidId`. The bid's standard `impid` must equal the request impression id mapped to +`slot_id`. A winner decision joins by exact `slot`, `candidateId`, `impid`, and +`slot_id` to exactly one bid. A no-bid or failed decision joins none. Missing, +duplicate, extra, or mismatched joins make the entire response invalid to TSJS. +TSJS renders only `render_source`; it never infers a source from standard OpenRTB +fields. If a bid also carries standard `adm`, it is permitted only for an ADM source +and must equal `render_source.adm` byte-for-byte; an `adm` on APS/cache or a mismatch +is invalid. `/_ts/page-bids` returns `BrowserAuctionProjectionV1`, and initial HTML +stores that same value at `tsjs.boot.auctionProjection`. They do not carry a second +legacy `{slots,bids}` interpretation. The deprecated `/__ts/page-bids` endpoint and +its JS fallback are deleted at cutover. + +### 3.3 APS response admission + +APS response handling is deterministic and reports typed local drop reasons: + +- upstream bid id is required, bounded to 64 UTF-8 bytes, and unique within the + provider response; +- malformed `contextual`, missing `creativeurl`, invalid tag type, invalid URL, + invalid dimensions, disallowed script, and malformed price are rejected per bid + where safe; one bad bid does not discard unrelated valid bids; +- dimensions must exactly match one requested size; values are never clamped; +- script creatives remain default-off and require an explicit security-approved + setting; iframe creatives remain supported by default; +- the validated AAX projection used in `aaxResponse` is derived from the accepted + bid, not re-parsed from a later lossy structure. + +Drop reasons must remain visible in the existing debug/log surfaces for all auction +entry points. This is not a new external telemetry contract. + +APS configuration accepts only canonical `account_id`; the `pub_id` deserialization +alias and its integer coercion are deleted at the hard cutover. + +### 3.4 Mediation provenance + +This work preserves the configured mediator's existing candidate selection and +timeout fallback behavior. It changes only the unsafe reconstruction boundary for +renderer-bearing source candidates: + +1. Candidate provenance is `(provider_name, upstream_bid_id)`. Missing, duplicate, + or oversized upstream ids are rejected. +2. Every candidate receives a response-unique, opaque, server-minted 12-character + base64url `candidate_id` from 9 CSPRNG bytes. Generation retries a + response-local collision at most eight times, then fails the affected slot with + `internal_error`; the id is never an ordering key. +3. The mediation request carries it only at + `ext.trusted_server.candidate_id`. A mediator-selected source candidate must echo + exactly one known id. Missing, unknown, or duplicate echoes are + `mediation_failed` and cannot borrow render data from another candidate. +4. The resolved candidate takes only the mediator-selected price and existing + selection metadata from the mediator. Provider, upstream id, render source, + dimensions, currency, and notifications come from the stored source candidate. + Mediator-native render sources are rejected as out of scope. +5. Direct no-mediator selection keeps the repository's current highest-CPM rule; + an exact CPM tie is resolved deterministically by provider name and upstream bid + id. An APS bid explicitly declaring a non-USD currency is invalid. This design + adds no auction currency or winner-selection configuration requirement. + +### 3.5 Publisher projection and GAM targeting + +The publisher bid projection carries `renderSource: BidRenderSourceV1` intact on +every path, plus the exact `candidateId` and `rendererReservationId`. Initial HTML +stores the document-generation input at +`tsjs.boot.auctionProjection: BrowserAuctionProjectionV1`. The initial +`NavigationSession` seeds its internal current projection from that immutable boot +value; an SPA page-bids response replaces only the new session's internal projection +through the transaction in §2.5. It never mutates `tsjs.boot`. A winner decision +must join exactly one projected bid and a no-bid/failed decision must join none. +Targeting applies one identity rule from §2.2 and never truncates a value to fit GAM. +If the chosen value cannot satisfy the 40-character targeting limit, the bid is +rejected before targeting with an explicit local reason. + +Targeting cleanup is owner-and-value checked, not value-only. One runtime-owned +journal stack per physical GPT slot and targeting key records an internal owner id, +the exact installed string, and the predecessor value/owner for every TS write. +The sole GPT adapter observes each live slot's `setTargeting` and `clearTargeting` +calls and uses a closure-private reentrancy marker for TS-originated writes. Before +forwarding any publisher-originated mutation it invalidates the affected key's TS +restoration chain—or every chain for clear-all—regardless of whether the publisher +writes the same string. This bookkeeping never changes the publisher call's +arguments, return, throw, or order. +Before a write, a mismatch between the actual GPT value and the current TS frame +means publisher code changed the key; the runtime drops its restoration chain and +preserves that publisher value. Otherwise the new attempt pushes a distinct owner +frame before setting the value, even when its string equals the predecessor's. + +Supersession, empty render, terminal failure, and navigation disposal mutate GPT +only when the disposing frame is current owner and the actual string still equals +its installed string. A current provisional frame then restores its immediate live +predecessor, or the original publisher value/absence. Disposing an older frame below +a newer owner performs no GPT write and rebases the successor to the removed frame's +predecessor. Acceptance promotes the new attempt's frames into its +`CommittedRenderArtifact`; disposal of the prior accepted artifact uses that same +non-top rebase. Thus two generations installing an identical string remain distinct: +newer success cannot be cleared by older disposal, newer failure can reveal the +still-live older value, and a publisher mutation is never overwritten. + +Publishing a TS-owned PUC bid is one ordered transaction. The browser first +validates the winner, tagged source, slot join, and server-minted reservation id and +prepares all targeting/bid objects without exposing them. It then inserts the +reservation as live in the bounded store. Store capacity or collision therefore +fails before a creative can observe the id. + +For SSAT/page-bids, only after successful insertion may it expose that same id as +GAM `hb_adid`, publish other targeting, record GPT intent, and invoke a +request-capable GPT operation—in that order. Any failure before request invocation +tombstones the reservation, compare-restores targeting, and settles the attempt. + +For the Trusted Server Prebid adapter, the supported artifact is the content-addressed +external bundle built from exactly lockfile-resolved Prebid.js 10.26.0. The external +artifact contains no Trusted Server auction, admission, render, or refresh behavior. +The TS-owned `PrebidAdapter` exposes one internal version-pinned +`admitTrustedBid(preparedBid)` boundary: + +```ts +interface PreparedTrustedBid { + readonly auctionId: string + readonly adUnitCode: string + readonly bid: Readonly<{ + requestId: string + adId: string + cpm: number + width: number + height: number + ad: '' + ttl: 300 + creativeId: string + netRevenue: true + currency: 'USD' + bidderCode: string + meta: Readonly<{ + advertiserDomains: readonly string[] + tsAuctionId: string + tsBidId: string + tsAdmHash?: string + }> + }> +} + +interface PrebidAdapter { + admitTrustedBid( + preparedBid: Readonly + ): 'admitted' | 'not_admitted' +} ``` -Kernel imports nothing above it; adapters import kernel only; services -import kernel + adapters; integrations import kernel + services, never -each other; stateful services via the G3 registry only; enforced per -G3's two rule families. - -### 7.2 Adapters - -`present | pending | timed_out` per external global; `timed_out` -non-terminal; queued operations carry their own timeouts and expire with -disposition reasons. - -### 7.3 Slot registry service - -Kernel-owned; `WeakMap` + div-id index; -ownership, adoption, handoff claims, responsive resolution, the G4a -causal intent queue (NavigationSession for unissued intents) and -cycle/drain state (RuntimeSession), targeting history. No expandos -(`__tsRenderGeneration`/`__tsRenderBid` deleted). - -### 7.4 Final global surface (hard cutover) - -| Legacy surface (removed at cutover) | Final shape | -| --------------------------------------- | --------------------------------------------------------------------- | -| `window.tsjs.que` | `window.tsjs.que` — unchanged | -| `globalThis.tscreative` | `tsjs.creative.*` | -| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | -| `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) | -| `window.__tsjs_*` flags, config globals | `tsjs.boot.*` | -| install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) | -| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` | -| `tsjs._internal` | kernel registry (G3), frozen after boot | -| (new, public) | `tsjs.definePlugin({id, release, install})` | - -Bootstrap: field-wise idempotent init (`window.tsjs ||= {}; tsjs.que -||= []; tsjs.boot ||= {}`; `publisher.rs:3665`'s clobber fixed); -transactional ownership `unclaimed → installing → kernel | fallback` -with an owner-generation counter; kernel installs inert and flips at one -commit point; throws unwind to `failed`; the 10 s watchdog aborts the -owner-generation-scoped controller, completes the shared unwind, then -atomically transitions `failed → fallback`; late continuations and -disposers validate the owner generation and self-discard; a bundle -arriving after fallback committed defers (`bundle_partial`). Tests: -throws per checkpoint and hung-resume-after-fallback. - -### 7.5 Messaging module - -All `postMessage` through one module: versioned envelopes, name -constants, G4b nonces, §6.8 validation. Minimal module in Phase 1; full -migration in Phase 4. - -### 7.6 Plugins and sessions - -`tsjs.definePlugin({id, release, install})`; **no plugin-level dispose -hook** — `ctx.onDispose` only, exactly-once reverse order. -`install(ctx): void | Promise` with `ctx.signal`, unwind on -throw/reject/abort, per-disposer isolation, disposer-after-disposal -invoked immediately, pending capacity 16 / 10 s → `bundle_partial`, -release mismatch quarantined before install. Sessions: `RuntimeSession` -(bridge listener + reservation store, history hook, pbjs subscriptions, -adapters, beacon queue, cycle/drain state, in-memory diagnostic -credential), `NavigationSession` (trace + auth + renewal timer, -attempts, aliases, unissued intents, targeting history), -`RenderAttempt`/`AuctionBatch`; enumerable disposal inventories. No -empty `catch`; console logging retained (paired `warn` with the beacon -reason; `debug`-level delivery/security failures promoted). - -### 7.7 Bootstrap - -`gpt_bootstrap.js` shrinks to a queue-and-flags stub; the bundle replays -recorded calls; the no-bundle fallback is generated from the same -TypeScript source (pinned by `gpt.rs:1174-1179`), activated per §7.4. - -### 7.8 GPT correctness carried with the restructure - -Unconditional early `slotRequested`/`slotRenderEnded` subscription -(replacing `gpt/index.ts:1091`'s gate); restore #922/#997 (DR-3); -`changeCorrelator: false` (configurable); `enableSingleRequest()` only -when services are not already enabled; ambiguous responsive resolution -emits `render_terminal{failed, slot_unresolved}` alongside its warning. - -### 7.9 Decomposition targets - -| Today | Target | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | -| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics | -| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | -| `core/trace.ts` (model + UI) | `services/trace` + `integrations/trace_overlay` | -| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal | - -### 7.10 Performance (reproducible) - -Pinned workflow (`runs-on: ubuntu-24.04` — browser CI is `ubuntu-latest` -today, `integration-tests.yml:155` — inside a pinned container digest); -lockfile-resolved Playwright with recorded browser revision -(`browser/package.json:10` is a caret range); pinned compressors -(`gzip -9 -n`, `brotli -q 11`). Vectors: minimal `[core]`; reference -`[core, creative, gpt, prebid, datadome]`; maximal all 13. Budgets vs -checked-in baselines (+5% bytes; baseline records image/browser/tool -versions and is invalid if any differ). Browser timing: -`performance.mark("tsjs:bids-script")` → -`performance.mark("tsjs:first-display")`; reference fixture; warm cache; -local resources; 5 warm-ups, 50 samples, nearest-rank p90 ≤ baseline × -1.10; inconclusive (3-run agreement > 5%) → one rerun, then fail. -**Retained-heap budget (named accurately — this measures retained, not -transient allocation peak):** via the Playwright CDP session — -`HeapProfiler.collectGarbage` then `Runtime.getHeapUsage`, sampled at -five fixed points (post-boot, post-adInit, post-first-render, -post-refresh, post-SPA-navigation) on the maximal vector; metric = max -retained sample; gate ≤ baseline × 1.10; same rerun rule. (Transient -allocation peak is out of scope; if a future leak needs it, add -`HeapProfiler` continuous sampling as a separate budget.) Server -benchmark: the G5 lookup path; 100 warm-ups, 1,000 iterations; median + -p90 one-sided ≤ baseline × 1.10; 3-run 5% agreement or inconclusive. - -### 7.11 Toolchain - -TypeScript floor to the resolved 5.9 line; release-gating flags -`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, -`verbatimModuleSyntax`, `noImplicitOverride`, -`useUnknownInCatchVariables`. The gate is a **checked-in package.json -script**, `"typecheck": "tsc -p tsconfig.json --noEmit"`, invoked so the -lockfile-resolved compiler is used: +All strings and numbers already satisfy their canonical auction/projection bounds; +`adId` is the exact admitted `r1_` reservation, and no renderer descriptor, ADM, cache +coordinate, or other capability crosses this boundary. The empty `ad` is deliberate: +the TS bridge resolves the already-stored tagged render source only after the PUC +claim. The adapter owns the bound Prebid object, registered `trustedServer` bidder +adapter, and the exact 10.26.0 response-admission callback; neither the external +artifact stamp nor publisher code exposes this method. + +All validation, reservation insertion, exact replacement of the TS bid's `adId`, and +frozen bid-object construction complete before the call; no targeting or other +publisher-visible mutation precedes it. The boundary returns exactly +`admitted | not_admitted`, and the 10.26.0 artifact fixture proves `not_admitted` +leaves no bid/event/targeting state. `not_admitted` tombstones the reservation and settles +`failed{reason:'prebid_admission_failed'}`. A throw settles the same failure; detected +partial publication tombstones the id, suppresses every later PUC request, settles +`failed{reason:'prebid_contract_violation'}`, and fails the artifact-conformance +gate. Runtime handling is fail-closed even though the same violation blocks future +release. A Prebid version change requires a reviewed contract-fixture update. + +`admitted` moves the reservation into an `awaiting_prebid_selection` state; it does +not create a render attempt or transfer permanent ownership. The Prebid adapter's +early synchronous `auctionEnd` listener uses the supported artifact's exact +auction-id/ad-unit winner query before publisher targeting callbacks. It promotes +only the exact selected TS `adId` to a render attempt and atomically tombstones every +other TS reservation admitted for that auction/ad unit as unselected. A ten-second +watchdog from admission performs the same tombstoning and records +`prebid_selection_timeout` if no matching `auctionEnd` arrives. Navigation or auction +abort tombstones the whole admitted set immediately. Subsequent GPT/render failure +follows the normal terminal lifecycle for the selected reservation. A fast Universal +Creative request can never race ahead of reservation lookup, and a losing bid cannot +hold capacity until the 15-minute tombstone expiry as a live entry. + +### 3.6 Static renderer and APS runner proxy endpoints + +`/integrations/aps/renderer/v1` and `/integrations/aps/runner.js` are always-reserved +Trusted Server routes. When APS is enabled, `GET` returns the local static renderer +or proxies the APS-hosted creative runner respectively. When APS is disabled, `GET` +returns a local `404 no-store`; neither route ever falls through to a publisher +origin. The family is dispatched before publisher auth, EC, and generic integration +filters. All adapters expose the same method, routing, security-header, and failure +semantics. Unsupported methods return local `405` with `Allow: GET`; unknown +renderer versions and the abandoned `/integrations/aps/runner/v1.js` shape return a +local `404 no-store`. + +The renderer v1 body and headers are immutable and served with a long-lived immutable +cache policy; a renderer-body or CSP semantic change requires a new renderer route +version. The document is static and contains no descriptor data. Its iframe +`sandbox` attribute and response CSP `sandbox` directive contain exactly this token +set, serialized in this order: + +```text +allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation +``` + +They omit `allow-same-origin`, `allow-top-navigation`, downloads, modals, +presentation, orientation lock, and storage-access escape. The exact renderer v1 CSP +header is: + +```text +default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' 'self' https:; connect-src https:; frame-src https: data: blob:; img-src https: data: blob:; media-src https: data: blob:; style-src 'unsafe-inline' https:; font-src https: data:; worker-src https: blob:; form-action https:; +``` + +The broad HTTPS resource directives are confined below the opaque outer sandbox and +are required for APS and bidder resources; `'self'` permits the local runner proxy in +HTTP-based hermetic adapters as well as production HTTPS. The response also has +exactly `Content-Type: text/html; charset=utf-8`, +`Cache-Control: public, max-age=31536000, immutable`, +`X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. It deliberately +omits both `X-Frame-Options` and a CSP `frame-ancestors` directive so publisher and GAM +creative ancestors can embed it; the iframe/CSP sandbox, nonce, source-bound port, +and descriptor validation are the embedding boundary. It does not forward publisher +credentials or authorization in TS-owned fetches. Runner-created APS creative +resources may use APS-origin cookies under ordinary browser policy. No cookie is +authority. Any header or CSP change requires renderer v2; policy reporting is a +separate observability design. + +The runner endpoint is a thin runtime transport proxy, not an artifact store. Its +only upstream is the fixed APS URL +`https://client.aps.amazon-adsystem.com/prebid-creative.js`; no request field, +configuration value, query parameter, or header can select another target. Trusted +Server issues a credential-free, referrer-free `GET`, does not forward browser +cookies, authorization, client IP, or publisher headers, requests +`Accept-Encoding: identity`, and disables redirect following in the platform client. + +The platform HTTP abstraction exposes an internal `ProxyResponseEvidenceV1` policy +used only by this route. It preserves upstream status and security-relevant evidence +for every occurrence of `Content-Type`, `Content-Encoding`, and `Content-Length` +before the generic proxy removes headers or consumes the body. An adapter that +exposes duplicate fields separately returns every raw value. An adapter runtime that +combines duplicates may return its exact combined value only when the combination is +visible and cannot be mistaken for a valid singleton; the closed grammars below +reject any comma/list form for all three headers. It is forbidden to split a combined +value or normalize it into an apparently valid singleton. All other upstream headers +are irrelevant because the successful response drops them. + +The policy requests identity encoding, prevents redirect following, and returns the +identity body as a bounded stream or bounded buffer without transforming bytes. +Cloudflare inspects the initial Workers `Headers` values before the generic adapter +strips encoding/length; concatenated duplicate values remain combined and therefore +fail the singleton grammars. If the runtime erases encoding evidence or decodes a +non-identity response without exposing that fact, the evidence is `unavailable` and +the proxy rejects it. Fastly and Axum apply the same evidence/no-decompression +contract. No adapter may reconstruct erased evidence. + +The entire upstream operation—from dispatch through the final response-body byte—has +a five-second monotonic deadline. Deadline expiry cancels the platform pending +request, discards every collected byte, and makes any unavoidable late platform +continuation self-discard without constructing a response. It returns the same local +failure as any other proxy error and cannot outlive the ten-second APS-completion +deadline. Spin does not use its current eager `spin_sdk::http::send` path for this +policy: it calls the WASI HTTP outgoing handler with supported request options and +polls the response/body stream against a monotonic-clock pollable for the total +deadline. Failure to set the transport timeouts or cancel/drop the pending resources +is `unavailable` evidence and prevents APS from being enabled on that build. + +The proxy accepts only status `200`. `Content-Encoding` must be absent or exactly one +case-insensitive `identity` token; lists and every other coding are rejected. Exactly +one parseable `Content-Type` header is required. Its case-insensitive essence must be +`application/javascript` or `text/javascript`, with either no parameters or only one +case-insensitive `charset=utf-8` parameter; duplicate or unknown parameters and every +other media type or charset are rejected. The exact body cap is +`APS_RUNNER_MAX_RESPONSE_BYTES = 8 MiB` on every adapter. If exactly one canonical +decimal `Content-Length` matching `^(0|[1-9][0-9]*)$` is present, it is rejected +before collection when above the cap and must equal the final identity-body byte +count. Missing length is allowed; duplicate, malformed, mismatched, or conflicting +values fail. Collection stops and cancels the request as soon as streamed or buffered +bytes exceed the cap. The complete body must decode as UTF-8 without replacement; +validation never transforms the bytes. + +A successful response relays the upstream body bytes unchanged while replacing +transport headers with +`Content-Type: application/javascript; charset=utf-8`, +`Access-Control-Allow-Origin: *`, +`Cross-Origin-Resource-Policy: cross-origin`, +`X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. Upstream +cookies and all other response headers are dropped. Upstream fetch, status, media +type, content encoding, redirect, length, size, or deadline failure returns a local +empty `502 no-store`; vendor bodies are never exposed in logs or error responses. +The adversarial parity corpus executes through each real adapter transport, including +Cloudflare and Spin wasm, rather than injecting already-normalized core responses. An +adapter that cannot preserve the raw evidence, common cap, or total deadline cannot +enable APS and blocks the release. + +No APS runner bytes, digest, vendor-version record, redistribution license, update +script, generated artifact, or offline fallback is stored in Trusted Server source or +release artifacts. This design does not define runner caching behavior. The runner +route is intentionally unversioned because it represents a live APS-owned dependency, +not immutable TS-owned bytes. + +Proxying does not make the runner trusted TS code or prove that it rendered. APS +remains the runtime owner of those executable bytes and its resolve/reject semantics +are a narrow external trust dependency. The outer opaque iframe, validated +descriptor, one-shot lifecycle port, and completion deadline contain execution and +reject missing, late, or misbound signals; they cannot determine whether APS told the +truth when it invoked `resolve`. The proxy never rewrites, inspects, or repairs the +JavaScript body. + +The renderer accepts one parent-provided, nonce-bound descriptor plus the publisher +origin captured by the kernel before iframe creation. Because its opaque origin has +`location.origin === "null"`, it validates the supplied origin's shape and uses it +only to repeat the descriptor's not-publisher-origin check. It clears the nonce from +its URL and implements the TS side of `ApsRunnerContractV1`. Before runner load, it +creates a one-shot Promise and queues exactly: + +```ts +new CustomEvent('prebid/creative/render', { + detail: { + aaxResponse: renderer.aaxResponse, + seatBidId: renderer.bidId, + source: 'internal', + resolve, + reject, + }, +}) +``` +`resolve` and `reject` are the Promise's one-shot functions and are the only +non-serializable fields. The renderer resolves `/integrations/aps/runner.js` against +its own absolute Trusted Server document URL and loads only that route. It creates the +script with `crossOrigin='anonymous'` and `referrerPolicy='no-referrer'`; it does not +set SRI because the proxy relays a live APS-owned artifact rather than immutable +TS-owned bytes. Under the APS conformance contract, the runner consumes the queued +event and promises to call `resolve` only after its asynchronous handler commits the +nested creative iframe, and to call `reject` for validation, load, or render failure. +Promise resolution sends one completed result; rejection, proxy/CORS error, runner +error, or script-load error sends one failed result over the transferred lifecycle +port. Runner `load` is intermediate progress only. The static renderer owns no APS +completion timer. Proxy/CORS/script-load failure maps to `runner_no_load`; callback +rejection maps to `runner_failed`. + +A locally authored fictional fixture implements the exact proxy and +queue/resolve/reject behavior; it is not a copy, transformation, or derivative of the +APS body. Real-GAM tests exercise the live proxied APS dependency in all three +browsers and are a release prerequisite. The APS runner is allowed to change +upstream. Load failure, explicit rejection, and silence fail closed through the +existing APS-completion deadline. A changed or compromised APS runner can invoke +`resolve` prematurely or incorrectly; this is an accepted external-dependency risk +that the outer renderer cannot detect. Real-browser DOM/network conformance reduces +but does not eliminate it. V1 never loads the APS URL directly from the browser, +executes a stored fallback, treats script `load` as completion, or uses a reusable +global `postMessage` acknowledgement. + +## 4. Render lifecycle protocol + +### 4.1 State machine + +```text +created + -> waiting_for_gam_and_claim | rendering_direct +waiting_for_gam_and_claim + -> waiting_for_owner | failed | cancelled +waiting_for_owner + -> waiting_for_insertion | failed | cancelled +waiting_for_insertion + -> waiting_for_document | waiting_for_adm | failed | cancelled +rendering_direct + -> waiting_for_document | waiting_for_adm | failed | cancelled +waiting_for_document + -> waiting_for_aps_completion | failed | cancelled +waiting_for_aps_completion + -> accepted | failed | cancelled +waiting_for_adm + -> accepted | failed | cancelled ``` -npm --prefix crates/trusted-server-js/lib run typecheck + +Transitions are methods on `RenderAttempt`, not ad-hoc flag mutation. Each method +checks the expected state and terminal latch. Timers are created at the transition +whose deadline they enforce and are cleared by the transition that settles them. + +An accepted transition first atomically promotes durable DOM/targeting ownership +from the attempt into one `CommittedRenderArtifact` owned by the exact slot and +navigation. The attempt disposer removes only uncommitted resources; promotion +detaches the committed iframe, targeting snapshot, and physical-slot metadata before +the terminal latch disposes the attempt. Direct TS iframes are removed by artifact +replacement or navigation disposal. PUC content remains owned by its physical GPT +slot: for a TS-owned slot the artifact may dispose it only through safe GPT +destroy/redefine, while publisher-owned slot DOM remains publisher-controlled and +the artifact releases only TS metadata and compare-restorable targeting. Before a +slot publishes another accepted artifact it disposes the prior artifact. Navigation +disposes artifacts according to those same ownership/quarantine rules. Claim, +registration, owner-control, and renderer-document ports/listeners that are no +longer needed close after terminal settlement and are never promoted. + +### 4.2 Universal Creative claim + +The supported GAM creative pins Prebid Universal Creative 1.17.2 by exact artifact, +not `latest` or a publisher-selectable version. Its cross-domain request is a JSON +string decoding to exactly +`{message:"Prebid Request",adId,adServerDomain}` and carries exactly one transferred +response port. All three values are strings; `adId` and `adServerDomain` are +nonempty. Object-form or extended payloads are rejected. Universal Creative owns +this shape, so it cannot carry a TS nonce. The checked-in hermetic PUC fixture is +generated from or pinned byte-for-byte to the supported source behavior. + +The bridge is one capture-phase dispatcher installed as the first reversible core +effect in the synchronous activation barrier, before any integration-module +activation and before any TS-owned GPT or Prebid script injection. This guarantees +it precedes native non-capture listeners installed later by TS while leaving no +dispatcher active during asynchronous preparation; +publisher capture listeners that already ran are inside the publisher trust +boundary. The runtime's dispatcher owns both the initial-request branch below and +the owner-registration branch in §4.3; no second global listener exists. It performs +this order: + +1. Perform only minimal, side-effect-free recognition. For a JSON string or a + clone-safe plain object, inspect own data properties only and extract string + `message`, `adId`, and an optional string `lifecycleTicket`. Do not read accessors + or traverse prototypes. Malformed or unrecognizable data is ignored. +2. Route `message === 'TS Render Owner Register'` to §4.3. If + `message !== 'Prebid Request'`, ignore it. For `Prebid Request`, look up the + extracted `adId` in the global reservation/tombstone store before exact parsing, + port, or slot-local checks. +3. For a non-TS id, do not suppress native Prebid. For a live or tombstoned TS id, + immediately call `stopImmediatePropagation()` so an extended/object-form request, + wrong port count, stolen capability, or replay cannot fall through. +4. Only after suppression, require the supported exact JSON-string shape and exactly + one transferred port. A recognized TS id with invalid shape/port is generically + refused when a usable port exists; all available ports are closed. It never + reaches native Prebid. +5. The first exact live claim during the compatible active GPT cycle acquires the + authoritative PUC `WindowProxy` from `MessageEvent.source` and stores that source + with its response port. This is the only source acquisition step; SafeFrame + ancestry is neither inspected nor guessed. Later owner messages must come from + this exact source. The unguessable reservation id, exact active slot/generation, + and one-time consumption authorize the first claim. Same-realm publisher code is + already inside the documented trust boundary. +6. Join the buffered claim with an attributable nonempty `slotRenderEnded`. A claim + that arrives first is bounded by the owning GPT-cycle/attempt deadline, discloses + no render data, and holds only its source and port. A nonempty GAM result that + arrives first starts a three-second claim deadline. Only when both conditions are + true does the runtime atomically revalidate and consume the reservation. +7. Empty GAM, navigation disposal, supersession, an incompatible cycle, or the + attempt deadline closes a buffered port, tombstones the reservation, and settles + the attempt. A second claim is generically refused; it never replaces the first. + +The successful outer response is an exact JSON string: + +```ts +{ + message: 'Prebid Response' + adId: string + renderer: string + rendererVersion: '3' + tsOwner: { + version: 1 + status: 'ready' + kind: 'aps' | 'adm' + lifecycleTicket: string + } +} +``` + +`renderer` is the checked-in TS dynamic-owner program. `lifecycleTicket` is +`t1_` plus 22 unpadded base64url characters from 16 CSPRNG bytes, bound to the +attempt, generation, source, and reservation, with a fixed three-second TTL from +posting the outer response. No descriptor or ADM appears in the outer response. A +recognized TS id that cannot be served receives the same response shape with +`tsOwner:{version:1,status:'refused'}` and no other `tsOwner` keys; the dynamic owner +rejects immediately, causing PUC to emit its ordinary `adRenderFailed`. If no usable +response port exists, the listener can only suppress and close available ports. +The claim deadline expiry is `bridge_claim_timeout`. + +### 4.3 Owner-control registration + +PUC executes a dynamic renderer in a hidden `__pb_renderer__` iframe, so a global +message posted by that hidden frame cannot satisfy source binding. The TS owner must +instead call PUC's supplied `h.sendMessage(type,payload,onResponse)` helper. PUC +adds `adId`, serializes the request, sends it from the original PUC frame, and +creates the response channel. + +The owner calls: + +```ts +h.sendMessage( + 'TS Render Owner Register', + { version: 1, lifecycleTicket }, + onRegistrationResponse +) +``` + +The kernel therefore receives exact JSON +`{message:"TS Render Owner Register",adId,version:1,lifecycleTicket}` from the +captured PUC `WindowProxy` plus exactly one helper-created response port. It +atomically consumes a live ticket and checks the exact `adId`, source, attempt, and +generation. Success posts exact JSON +`{message:"TS Render Owner Registered",adId,version:1,lifecycleTicket}` on that +response port and transfers exactly one newly-created owner-control port. Refusal +posts exact JSON `{message:"TS Render Owner Refused",adId,version:1}` with no +transferred port. These are the only registration responses. + +This message is received by the same capture-phase dispatcher from §4.2. Its +registration branch first looks up the minimally extracted `lifecycleTicket` in the +runtime ticket/tombstone map. An unknown ticket is ignored for native/publisher +listeners. For a live or tombstoned TS ticket it immediately calls +`stopImmediatePropagation()` before exact JSON-shape or port validation, then checks +the captured source, exact `adId`, ticket, attempt, generation, and exactly one port. +A recognized invalid/replayed request is generically refused when a usable port +exists and all available ports are closed. Ticket settlement, consumption, or +attempt disposal replaces its live entry with a tombstone through the original fixed +ticket expiry; expiry prunes either live or tombstoned state. The dispatcher itself +is runtime-owned; attempt disposal performs that registry transition and removes +attempt handlers, not the global dispatcher. + +The owner starts a three-second watchdog before invoking `h.sendMessage`, calls the +helper's returned stop-listening disposer after the first response, and rejects on +timeout, refusal, malformed data, or a port count other than one. The kernel owns +the opposite control port and the owner owns the transferred port. Replay, wrong +source, wrong port count, stale generation, or expiry cannot bind a channel. Kernel +and owner each have a terminal latch; late registration, acknowledgement, +settlement, or watchdog callbacks close their ports and remain inert. + +### 4.4 APS document and runner acknowledgement + +After registration, the kernel posts this exact control message and transfers +exactly one renderer-document port: + +```ts +{ + message: 'TS APS Start' + version: 1 + lifecycleTicket: string + rendererUrl: string + envelope: { + version: 1 + nonce: string + publisherOrigin: string + renderer: ApsRendererV1 + } +} +``` + +The kernel owns the opposite document port. The owner creates exactly one iframe at +the versioned renderer URL with the nonce in its fragment, reports exact +`{message:"TS Owner Inserted",version:1,lifecycleTicket}` on the control port, and +on iframe load transfers the envelope and document port once to that exact +`contentWindow`. Direct APS uses the same document channel and envelope but has no +PUC owner-control channel. The nonce is 128-bit CSPRNG, attempt-bound, and one-use. + +The static document sends only these exact document-port messages: + +- `{message:"TS APS Document Accepted",version:1,nonce}` after nonce and descriptor + validation; +- `{message:"TS APS Runner Loaded",version:1,nonce}` when the runner script loads, + as nonterminal progress; +- `{message:"TS APS Render Completed",version:1,nonce}` when the queued APS render + event invokes its one-shot success callback; +- `{message:"TS APS Render Failed",version:1,nonce,reason}` where `reason` is + `descriptor_invalid | runner_no_load | runner_failed`. + +Insertion has a one-second deadline, document acceptance has a three-second +deadline from iframe insertion, and the kernel is the sole owner of the ten-second +APS-completion deadline beginning at document acceptance. Callback silence at that +deadline maps to `runner_failed`; the static renderer never starts a competing +completion timer. Script load never accepts. Failure, timeout, port error, +supersession, or navigation disposal settles once. On a direct path the kernel +removes its exact pending iframe. On a PUC path the iframe is remote DOM owned only +by the dynamic owner; the kernel never claims it can remove that node and instead +posts exactly one owner-control settlement: + +```ts +type OwnerSettlementV1 = + | { + message: 'TS Owner Settled' + version: 1 + lifecycleTicket: string + outcome: 'accepted' + } + | { + message: 'TS Owner Settled' + version: 1 + lifecycleTicket: string + outcome: 'failed' + reason: RenderFailureReason + } + | { + message: 'TS Owner Settled' + version: 1 + lifecycleTicket: string + outcome: 'cancelled' + reason: 'caller_aborted' | 'superseded' | 'navigation_disposed' + } +``` + +The PUC owner owns the remote node, its DOM handlers, and its side of the control +port. An accepted settlement promotes that exact iframe as committed, removes its +temporary handlers, closes the control port, and resolves the renderer Promise once. +A failed or cancelled settlement removes the exact uncommitted iframe, removes its +handlers, closes the port, and rejects once so PUC emits its ordinary render-failure +event. The same cleanup applies to the PUC ADM/cache owner in §4.5. + +When registration accepts the transferred control port, before waiting for an APS or +ADM start message, the owner arms one fail-closed 20-second settlement/channel +watchdog. Start does not extend or rearm it. The deadline is longer than every +kernel-owned insertion/document/render deadline and also covers registration-to- +start loss. The owner cancels it on settlement. A malformed control message, +`messageerror`, local owner disposal, or watchdog expiry performs the failed/ +cancelled cleanup above and rejects once; a silently closed or lost control channel +is therefore bounded. This watchdog is remote resource cleanup only and cannot +report acceptance to the kernel or change its already-terminal outcome. A +settlement-post throw is isolated in the kernel because the remote watchdog owns +this failure path. + +A caller `AbortSignal` remains attempt-owned after owner registration. If it wins +the terminal latch, the kernel closes its renderer-document channel and sends the +exact cancelled/`caller_aborted` settlement; direct rendering also removes the +kernel-owned iframe. The PUC owner performs the remote cleanup just specified. A +later insert, load, document message, APS callback, settlement, or watchdog is inert. + +The winning descriptor dimensions are also the exact layout contract across all +three nested documents. Before inserting its renderer iframe, the PUC owner sets its +own document root and body to zero margin/padding with hidden overflow, then creates +one block iframe with matching positive width/height attributes and CSS pixels, zero +border, and no scrollbars. The static renderer document has the same zero +margin/padding and hidden-overflow root/body contract before loading the APS runner. +The runner-created descendant creative is expected to occupy the same viewport. A +300×250 winner therefore has 300×250 `clientWidth`, `clientHeight`, `scrollWidth`, +and `scrollHeight` in the PUC owner and renderer documents, and a 300×250 descendant +viewport, with no default eight-pixel body margin, clipping, or overflow. Equivalent +assertions run for every boundary fixture dimension. This is layout correctness, not +render completion; the callback contract above remains the acceptance authority. + +### 4.5 ADM/cache ownership + +Cache first resolves to bounded, validated ADM through the transport in §3.1. Direct +and PUC ADM/cache use one TS-authored iframe constructor and this exact ordered +sandbox value: + +```text +allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation +``` + +It omits `allow-same-origin`, downloads, modals, presentation, pointer lock, and +storage-access escape. The iframe has `referrerPolicy='no-referrer'`, exact positive +source dimensions in both attributes and CSS pixels, zero border/margin, hidden +overflow, `display:block`, `scrolling='no'`, title `Ad content`, and aria-label +`Advertisement`. + +The constructor creates the iframe detached, installs one-shot `load`/`error` +handlers and disposal state, builds the complete ADM document, assigns exactly one +`srcdoc`, and only then appends the iframe once. It never appends an empty iframe, +sets `src`, or performs a TS-authored replacement navigation. A `load` is accepted +only when the exact frame is still the pending frame for the current attempt and +navigation generation, the intended `srcdoc` was assigned before append, and the +terminal latch is open. Initial `about:blank`, pre-assignment, removed-frame, +replaced-frame, stale-generation, post-disposal, duplicate, and error events cannot +accept. Error, removal before acceptance, or the five-second deadline fails +`adm_document_no_load`. Real-browser tests force initial-blank, replacement, +removal, error, timeout, supersession, and stale-load orderings. + +For PUC ADM/cache, the trusted TS-authored owner—not bidder creative code—uses the +same registration protocol. The kernel sends exact +`{message:"TS ADM Start",version:1,lifecycleTicket,source:AdmRenderSourceV1}` on the +owner-control port with no transferred port. The owner reports +`TS Owner Inserted` only after the shared constructor appends exactly one owned +iframe, and reports exact +`{message:"TS ADM Loaded",version:1,lifecycleTicket}` or +`{message:"TS ADM Failed",version:1,lifecycleTicket}`. The kernel answers with one +`OwnerSettlementV1`; only that settlement resolves or rejects the PUC renderer +Promise. Insertion has a one-second deadline and load has a five-second deadline. +Direct ADM uses the same constructor and accepts through its own terminal latch on +the intended load. No secret or capability is injected into bidder-controlled +markup. + +### 4.6 Channel ownership and parsing + +| Channel | Creator | Retained endpoint | Transferred endpoint | Lifetime | +| --------------------- | ------------------------- | ------------------- | ----------------------------------------- | ------------------------------------------------- | +| outer PUC response | PUC `prebidMessenger` | original PUC frame | kernel global listener | one ready/refused response or claim disposal | +| registration response | PUC `h.sendMessage` | original PUC helper | kernel global listener | one registered/refused response or owner watchdog | +| owner control | kernel after registration | kernel attempt | hidden TS dynamic owner | insertion through final owner settlement | +| renderer document | kernel before APS start | kernel attempt | exact static APS renderer `contentWindow` | document acceptance through APS completion | + +Global window messages are JSON strings with the exact keys specified above. Port +payloads are structured-clone objects with the exact keys specified above. Every +parser rejects accessors, wrong prototypes, unknown keys, wrong literal/version, +wrong port counts, oversized strings, and already-consumed capabilities before +performing a state transition. The disposer clears handlers, closes both locally +owned ports where possible, and makes queued callbacks generation-inert. + +The shared protocol corpus fixes these bounds and encodings: + +- before `JSON.parse`, an inbound global-dispatcher string is at most 4,096 UTF-8 + bytes; a larger value is unrecognizable and causes no property access or state + lookup; +- a TS `adId` is exactly the 25-character `r1_` reservation form; a lifecycle ticket, + renderer nonce, and attempt id are exactly the respective 25-character `t1_`, + `n1_`, and `a1_` forms from §2.2; +- `adServerDomain` is nonempty and at most 2,048 UTF-8 bytes. It is retained only for + exact PUC-shape conformance and is never a fetch target or authority; +- `publisherOrigin` and `rendererUrl` are at most 2,048 UTF-8 bytes. The former must + serialize an exact HTTP(S) origin with no path/query/fragment; the latter must equal + the current generation's absolute `/integrations/aps/renderer/v1` URL with no query + or fragment, after which the owner appends the exact `n1_` nonce fragment; +- a navigation/refresh generation is a nonnegative safe integer; and +- the generated dynamic-owner `renderer` program is at most 64 KiB UTF-8 and the + complete successful outer-response JSON is at most 72 KiB. Build tests enforce + both; refusal responses contain no renderer. + +Structured-clone port payloads use their exact field-level limits: APS descriptor +256 KiB decoded AAX; ADM 512 KiB; `creativeUrl`, cache `baseUrl`, and cache +`fetchUrl` 4,096 UTF-8 bytes; `publisherOrigin`, `rendererUrl`, and +`adServerDomain` 2,048 UTF-8 bytes; server slot id 1–256 UTF-8 bytes with no NUL or +ASCII control; and the fixed +capability forms above. No generic unbounded string remains. Boundary-minus-one, +boundary, boundary-plus-one, multi-byte UTF-8, duplicate-key, and malformed-encoding +cases run through the producer plus both the global dispatcher and port parsers. + +### 4.7 Notifications + +APS has no `nurl` or `burl`; none is synthesized. Existing notifications on other +bid formats remain nonblocking and exactly-once per accepted lifecycle transition. +Notification transport failure cannot change a render outcome. Redesigning or +measuring notification delivery is outside this spec. + +## 5. TSJS target architecture + +### 5.1 Layers + +```text +kernel/ boot, registry, queue, event bus, sessions, disposal, logging +adapters/ googletag, prebid, messaging +services/ slots, auction batches, render lifecycle, consent +integrations/ gpt, prebid, aps, creative, and existing publisher integrations +composition/ the sole construction root for adapters, services, and integration modules ``` -(the script runs `tsc` from the package's own `node_modules/.bin`, so no -`npx` path ambiguity). Dev toolchain bumps as individual CI-gated PRs; -`prebid.js` excluded from casual bumps; monthly review. - -## 8. Rollout - -Single-release state machine per §0. **Statistical method:** sampled -traces only (diagnostic reported separately); **randomization unit = -`assignment_id`** (minted inside the affinity token, §0; persisted on -client-event rows), so attempts cluster by session; the estimator is a -**checked-in cluster bootstrap** (`scripts/gates/estimator.py`): -resample assignment ids, 2,000 resamples, fixed seed recorded in the -gate artifact, strata (publisher × slot) weighted by control-arm -traffic share; one-sided 95% confidence bounds on **relative -differences**; each gate is an independent go/no-go (no cross-gate -multiplicity correction — stated). **"Missing telemetry counts as -failure" is made enforceable for whole-missing traces:** the server -writes a **sampled exposure row** (`ts_expected_attempts`, one per -server-observed eligible APS win in a sampled trace) at auction time; -gates **left-join client attempts to expected rows**, so a trace whose -client events never arrive is a visible missing attempt (not an absent -row that silently shrinks the denominator). A per-window -client-transport completeness ratio below 90% makes the statistical gate -**inconclusive** rather than passing on a biased sample. Per-flow floors; -rare flows (direct, fallback) gate hermetically + real-GAM, never -statistically. `cycle_unattributable` divides by all -attribution-candidate TS cycles. - -**Billing is the one gate the cluster bootstrap cannot run**, because GAM -aggregate reports expose only per-`ts_arm` totals, not session clusters. -Two options, one chosen per DR: (a) source billing from **GAM Data -Transfer / impression-level logs**, which carry the `ts_arm` key-value -**and** a joinable impression/`attempt_id` correlator, and run the same -cluster bootstrap over impression rows; or (b) if Data Transfer is -unavailable, use a **separate pre-reviewed aggregate estimator** with the -**day** as the randomization unit (a two-sample non-inferiority test over -daily per-arm RPM across the billing window), declared in the gate -artifact. Whichever is chosen, `ts_arm` is a **reserved, network-audited -key proven untargeted by any production line item and A/A-validated -before canary** (§0, §11), so it cannot perturb the metric it measures. - -**Phase 0 decision records:** DR-1 mediator presence; DR-2 script -creatives (deployment decision); DR-3 #997 vs re-merge; DR-4 -candidate-id echo owner (`merge_highest_cpm` config-blocked until -delivered); DR-5 non-Fastly sinks (splits Phase-2 gates, scopes -persistence gates). - -Phases: **0** identity/schemas/toolchain/DRs + gate artifacts -(pipes/scripts/workflows) + observation-only control build definition; -**1** kernel/sessions/minimal messaging/cycle registry/transactional -bootstrap — Phase-1 gates are **hermetic** (in-page counters; beacon -transport does not exist until Phase 2); **2** trace + beacon + renewal - -- diagnostic upgrade + probe issuance + four-adapter ingest + per-sink - probes + the alert drill; **3** APS delivery (schema crate + corpus; - mediation; render token + reservation store; renderer route + - three-message ack + CSP route; §6.8; G4a–G4g incl. AuctionBatch; - `notification_sent`; fallback; DR-3 restoration; correlator + SRA); - **4** structure (layering, plugins, adapters, registry, messaging, - namespace; four-flow parity); **5** decomposition + bootstrap stub + - parity rerun + **full Phase-3 statistical and real-GAM gates repeated - on the exact immutable RC** (attested per A.3) before weight-up, then - cutover per §0. - -## 9. Test acceptance matrix - -Hermetic CI blocks PRs; the real-GAM suite is release-gating (A.3). - -| Area | Coverage | -| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Attempts | `attempt_id` uniqueness; parent/child fallback linkage; exactly one `render_terminal` per attempt; parent `failed{gam_empty}` precedes `fallback_start` | -| Terminal model | discriminated outcomes incl. `no_bid`/`cancelled`; per-reason `source` nullability; validity-matrix conformance | -| Request cycles | disabled-initial-load `display()` retired for any caller; TS-noop-refresh → TS-display and publisher variants (same-class supersession); SRA; `intent_no_request`; overlap quarantine; no timeout re-arm; stale discard; real-GAM overlap | -| Ack per path | four G4b sequences; APS three-message; owner-observed ADM `load`/`error` with **nonce never in bidder realm**; bidder-synthesized message ignored; early-`burl` attempt fails; per-path deadlines; stale/replayed; after-disposal | -| Bridge signal | truncated/replaced `hb_adid` → `bridge_request{matched:false}` with no response and native Prebid untouched; stolen TS ids fully suppressed (neither TS nor native responds); listener order | -| AuctionBatch | multi-slot partial/full supersession; fetch aborts only when all children dead; stale-bid filtering through live child identity; timeout; navigation disposal; reversed responses; discriminated auction-client errors | -| Diagnostic | `dexp` ceiling — renewal-before-expiry capped, repeated renewal to ceiling then failure; credential byte-level vectors; fragment clearing; in-memory storage; pre-upgrade buffering then flush; forgery/wrong-origin/replay | -| Transport/limits | batch caps (64 events AND 12 KiB); ≤ 4 batches/flush; sustained single-tab, 5-tab, diagnostic flush, pagehide burst inside the 60/120 budget; overflow coalescing; Fastly synchronized-burst documented; unknown-address bucket | -| Affinity | token vectors (format, rotation, constant-time); state-dependent defaults (canary vs post-cutover reassignment; no stale-cookie stragglers); coherence for HTML/assets/APIs/beacons; **CSP affinity via `policy_id` path, cookie-less renderer reports** | -| Arms/measurement | observation-only control build emits comparable sampled telemetry (its zero-behavior-change gated by hermetic parity vs baseline); arm-specific datasources; union view stamps `deployment_pool`; `assignment_id` on rows; cluster-bootstrap fixture; GAM `ts_arm` key | -| Latency/fill | `t_rel_ms` monotonic bounds; `attempt_started`→`render_terminal` durations; per-gate numerator/denominator queries (A.1); named source tables/joins | -| Joins | `Nullable(UUID)` type equality; auction-level vs slot-level vs bid-level canonical joins; raw-join multiplication rejected | -| Probes | authenticated probe rows (`probe_run_id`/`expected_seq`/`adapter`/`target`) per datasource; secret-derived CSP probe `policy_id` unforgeable; probe issuance protocol; persistence gates scoped to sink-backed adapters | -| Alerting | injected-failure drill: alert fires ≤ 1 h | -| Kill switch | switch state via HTML and response extensions; attempts created after delivery honor it before each irreversible action (incl. before `nurl`); SSAT-on-stale-page exemption documented and tested | -| Drop enum | `empty_seatbid_bids` + `bid_id_too_large` mapped; shared typed enum across producers; compile-time exhaustiveness | -| RC attestation | real-GAM workflow consumes the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}` and emits it in the attested output; gates parameterized by (release, pool, epoch); RC re-canary inherits each row's window | -| Config/affinity | `config_hash` SHA-256 over exact bytes verified at startup (mismatch = fail); affinity HMAC vectors + rotation + constant-time | -| Heap | CDP procedure at the five fixed points; GC before sample; rerun rule | -| Lint | custom scope-aware rule catches `window`/`globalThis`/`self` member access and same-file aliases outside adapters (claim scoped to these shapes) | -| Notifications | dispatch mechanics (no-cors GET, no-referrer, keepalive; `Image()` fallback; server-side macro expansion only); `notif_id` emission; duplicate alarm + reconciliation; hermetic exactly-once | -| Mediation | required-unique bounded upstream ids (`missing`/`duplicate`/`bid_id_too_large`, no fingerprint); `candidate_id` echo; arrival-order shuffle invariance; authoritative-field rules; provenance fail-closed scope; strategy timeouts; both lifecycles; APS + non-USD startup error | -| Render token | format/CSPRNG/retry/TTL/one-time; scope; union capacity 320 with `registry_full`; >320 then late oldest-id suppressed | -| Trace auth | auth ≤ 256 B; encoding vectors; expiry/skew/max-future; renewal preserves mode; renewal-after-expiry fails; previous-key retention; deterministic sampling (exact u64 threshold; same trace → same mode concurrently) | -| Internal routes | wrong-method 405 + `Allow` + `no-store`; unknown version 404; no fall-through; dispatch before filters; no forwarding; per-family origin policies | -| CSP | both media types; opaque/null origin; policy-id path identity (forged body ignored); bucket caps + overflow counter; three-browser capture; per-version frozen header manifest | -| Schema | staleness; adversarial corpus ×3 validators; outer tolerance vs exact AAX; generated validity matrix | -| ABI/plugins/boot | one kernel; exact-release verdicts; object-form release check; partial-install unwind; abort-pending; disposer-after-disposal; hung-resume self-discard; fallback-then-late-bundle deferral | -| Lifecycle | `timed_out → present`; disposal inventories; unissued intents cancelled by navigation; boot consume/freeze/delete; final-namespace smoke | -| Delivery | unknown hash 410 `no-store`; exact-match immutable; release-time vector materialization (unlisted = build error); cutover rehearsal | -| Adapter parity | ingest, CSP-report, trace-auth, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | -| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness; kill-switch snapshot semantics | -| Perf | marks present; three vectors; heap CDP; inconclusive-rerun; pinned-environment baseline validity | - -## 10. Alternatives considered (complete) - -1. **Patch APS point-failures without telemetry** — rejected: four correct - fixes produced no reliable ads; the next would be another guess. -2. **Always direct-render APS** (skip GAM/PUC) — rejected: unilaterally - changes GAM reporting/pacing; kept only as the attributed-`gam_empty` - fallback. -3. **Single module graph / shared chunks now** — rejected for this release: - changes the delivery pipeline while everything else changes; successor - option behind the same registry surface. -4. **Full rewrite in one branch without phases** — rejected: the - browser-spec safety net is thinnest exactly where behavior changes. -5. **Dropping the ES5 bootstrap** — rejected: loses the pinned no-bundle - guarantee; the generated fallback keeps it. -6. **Timeout-triggered fallback rendering** — rejected: uncancelable GPT - requests race late fills → double-render/double-bill. -7. **Timeout-based quarantine re-arm** — rejected: recreates the - stale-event bug. -8. **N/N−1 compatibility machinery** — removed by the §0 policy decision. -9. **Client-computed notification hashes** — rejected: no key without - breaking the pseudonymization boundary; server-minted `notif_id`. -10. **Fingerprint identities for id-less bids** — rejected: can merge - distinct demand; rejection with closed reasons instead. -11. **Plain readable cohort cookie** — rejected: dark-pool opt-in + - cache-cardinality abuse; opaque authenticated token. -12. **`billing_outcome` event** — removed: no honest post-accept producer. -13. **Injected in-realm ADM reporter** — rejected: hands bidder code the - acceptance credential; owner-observed `load`/`error` instead. -14. **Cookie-routed CSP affinity** — rejected: opaque-origin reports carry - no cookie; server-selected `policy_id` path identity. -15. **Token-identity alone for arm attribution** — rejected: authorization - is not a queryable row dimension; the router injects a trusted internal - cohort header ingest stamps (§0). -16. **Indefinite diagnostic renewal** — rejected: signed `dexp` ceiling. -17. **Shared attempt identity for parent/child** — rejected: distinct - `attempt_id`. -18. **24 h affinity for a multi-day experiment** — rejected: the affinity - lifetime now covers the maximum experiment window (§0). -19. **`Image()` notification fallback** — rejected: it constructs a - separate credentialed/referrer-bearing request outside the primary - transport's privacy contract; a failed dispatch reports `failed` - instead (§G4d). - -## 11. Risks (complete register) - -- **Hard-cutover blast radius** — accepted by policy; bounded by the §0 - runbook (probes, low-weight canary, monitored window, weight-back). -- **Sticky-cohort routing is new infrastructure** the cutover depends on — - Phase 0 work; its coherence and affinity-lifetime tests are - release-gating. -- **Mediator wire-contract change** (`candidate_id` echo) — DR-4 gates - `merge_highest_cpm`; config validation enforces the block. -- **Published notification triggers** become a contract for PBS-path - demand — changing them later breaks SSP reporting. -- **Required `[auction].currency`/`winner_selection`** in mediated - deployments are a deliberate startup-error class under §0. -- **Beacon abuse** — pre-parse caps, per-family origin policies, - fail-closed numeric limits, signed modes, credentialed diagnostics. -- **Registry/limiter memory** — explicit capacities, TTL reclamation, - reject-at-capacity; Fastly overshoot documented, not claimed. -- **CSP data is advisory** — never a sole automatic rollback signal. -- **Sink blindness** — per-datasource authenticated probes. -- **ABI freeze** — `tsjs._internal.registry` is load-bearing; exact-release - verdicts are the contract. -- **Schema generation** — checked-in artifacts + staleness CI. -- **Observation-only control build** is new scoped work whose "zero - behavior change" property is itself gated by hermetic parity vs baseline - **and an A/A pre-canary** (the `ts_arm` GAM key must move no metric). -- **`assignment_id` persistence** is pseudonymous but new — bounded by the - affinity token lifetime and excluded from any identity join by schema - review; it reaches telemetry only via the router-injected trusted header - (§0), never a client field. -- **`ts_arm` GAM targeting key** could itself perturb line-item matching — - reserved and audited network-wide, proven untargeted by production line - items, and A/A-tested before canary (§8). - -## 12. Success criteria - -1. APS creatives render in each configured flow, hermetically and in the - attested real-GAM suite. -2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the - failing class from one page load; §5.7 SLIs hold, including the alert - drill. -3. Both lint families (incl. the custom scope-aware rule) pass; stateful - sharing only via the registry; exact-release mismatches quarantine - loudly. -4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or - generated. -5. Exactly one `render_terminal` per `attempt_id`; parent/child fallback - attempts are distinct rows; attempt aggregation keys on `attempt_id` - with the tuple as grouping only. -6. The only TSJS-owned global is `window.tsjs` (§7.4); no expandos. -7. §7.10 budgets hold, including the CDP heap procedure. -8. No existing warning lost; issue-surfacing conditions log `warn`+ with - the beacon reason. -9. TypeScript floor and flags via the checked-in `typecheck` script; - `prebid.js` pin documented. -10. `nurl`/`burl` only on carrying paths at their binds with the - normative dispatch mechanics; APS fires neither; hermetic - exactly-once + production alarm + reconciliation. -11. Trace-bearing responses `private, no-store`; authorizations signed, - mode-carrying, renewal-preserving, diagnostic bounded by `dexp`; - unsampled transmits nothing. -12. Cutover rehearsed; config-hash verification enforced; post-cutover - routing defaults flip (no stale-cookie stragglers). -13. Phase-3 statistical and real-GAM gates pass on the attested - immutable RC before weight-up. -14. Appendix A shipped with this design; changes carry decision records. -15. Baseline APS fix behaviors re-implemented; baseline browser tests - pass unmodified. - -## 13. Open questions - -One: does Amazon expose any creative-completion acknowledgement that -could add a post-`accepted` state under a new name (future -enhancement)? - ---- - -## Appendix A — Normative rollout gates (initial values) - -Roles: **RO** release owner, **QA** QA owner, **OPS** on-call. -Randomization unit: `assignment_id` (§0). Statistical gates: sampled -traces, cluster bootstrap (§8), canonical views only, checked-in -artifacts (`tinybird/pipes/gate_*.pipe`, `scripts/gates/*.sh`, -`.github/workflows/*`). Every statistical row specifies -numerator/denominator/source; missing rows count as failure. "Hold" = -weight frozen; "Rollback" = weight back + re-purge. Changes require -decision records. Low volume: inconclusive → extend once → Hold. - -### A.1 Phase gates - -| Phase | Gate | Artifact | Numerator / denominator (source) | Floor | Threshold | Window | Owner | Action | -| ----- | ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------- | ----- | -------- | -| 0 | Dark-pool health | `probe-pool.sh` | expected responses / probe requests | 1,000 | 100% | 24 h | OPS | Hold | -| 0 | Schema validation | `schema-writes.sh` | accepted / deterministic synthetic rows (all tables) | 10,000 | **0 rejections** | 24 h | RO | Hold | -| 0 | Asset identity | `asset-probe.sh` | correct status / probed hashes | all | 100% | once | QA | Hold | -| 0 | Config binding | `config-hash.sh` | verified pools / pools | all | 100% | once | OPS | Hold | -| 1 | Kernel/bootstrap (hermetic) | `bootstrap-ownership.spec` + counters | passing cases / cases (no beacon dependency) | all | 100% | CI | QA | Hold | -| 2 | Ingest HTTP parity | `ingest-parity.sh` | passing / parity cases (4 adapters × 4 families) | all | 100% | CI | QA | Hold | -| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | accepted probe events / sent (probe tokens) | 10,000 | ≥ 99%; dedup exactly-once | 24 h | OPS | Hold | -| 2 | Per-sink authenticated probes | `gate_probes.pipe` | on-time probe rows / expected (`probe_run_id×seq`), per datasource × sink-backed adapter | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | -| 2 | Alert drill | `alert-drill.sh` | alerts ≤ 1 h / injected failure episodes | 3 episodes | 100% | 24 h | OPS | Hold | -| 3 | Funnel ssat/prebid/page_bids | `gate_funnel.pipe` | per A.2 stage pairs (`ts_render_attempts_v` ⋈ slot-level auction rows) | 10,000/flow-arm | per A.2 | 24 h | RO | Rollback | -| 3 | Direct/fallback conformance | hermetic + A.3 rows | passing / suite cases | all | 100% | CI + RG | QA | Hold | -| 3 | Attribution soundness | `gate_cycles.pipe` | `cycle_unattributable` / **all attribution-candidate TS cycles** | 10,000 | < 0.5% | 24 h | RO | Rollback | -| 3 | GAM fill | `gate_fill.pipe` | nonempty `slotRenderEnded` / TS request cycles, canary vs control | 10,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 24 h | RO | Rollback | -| 3 | Latency | `gate_latency.pipe` | p95 of (`render_terminal{accepted}.t_rel_ms − attempt_started.t_rel_ms`) per arm | 10,000/arm | 1-sided 95% CB rel. diff ≤ +2% | 24 h | RO | Rollback | -| 3 | Billing | `gate_billing.pipe` + GAM `ts_arm` | revenue per 1,000 attempts per arm (GAM report ⋈ attempts) | 100,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 7 d (+7 d ext.) | RO | Rollback | -| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` + reconciliation | duplicate `notification_sent{burl}` per `notif_id` / dispatches; GAM-vs-server deltas | 1,000 | 0 observed; reconciliation within 1% | 24 h / billing wnd | RO | Rollback | -| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | -| 4 | Four-flow parity | `flow-parity.spec` | passing / parity cases | all | 100% | CI | QA | Hold | -| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / §7.10 tolerances | CI | QA | Hold | -| 5 | RC re-canary | all Phase-3 rows on the attested RC | as Phase 3 | as Phase 3 | as Phase 3 | **each row's own window** | RO | Rollback | -| 5 | Cutover monitor | `gate_slis.pipe` | probe freshness/loss; per-flow `render_terminal{failed}` rate vs pre-cutover canary | 10,000 attempts/flow | lag ≤ 5 min; loss < 0.1%; per-flow failed ≤ canary + 0.5 pt (flow-weighted; a below-floor flow holds, not passes) | 24 h | OPS | Rollback | - -### A.2 Expected stages per flow - -| Flow | Expected sequence | Stage thresholds (named denominators) | -| --------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_terminal{accepted}` | `targeting_set`/eligible wins ≥ 98%; each later stage ≥ 95% of prior; document/`bridge_response_sent` ≥ 99%; runner fail+timeout ≤ 1% of document | -| prebid | same (keyed by Prebid `adId`) | same | -| page_bids | same (post-SPA-navigation) | same | -| direct | `attempt_started → renderer_document_loaded → render_terminal{accepted}` | document ≥ 99% of attempts; accepted ≥ 95%; runner fail+timeout ≤ 1% of document (hermetic + real-GAM) | -| fallback | parent `render_terminal{failed, gam_empty}` → child `fallback_start → renderer_document_loaded → render_terminal{accepted}` | `fallback_start`/eligible parent `gam_empty` ≥ 99%; accepted ≥ 95% of starts; runner fail+timeout ≤ 1% of document (hermetic + real-GAM) | - -### A.3 Real-GAM suite (attested) - -| Field | Value | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | -| **Input** | **the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}`** | -| **Independent verification** | **the workflow does not trust its input**: it fetches the deployed pool's runtime `release_id`/`config_hash` from a trusted control-plane endpoint, hashes the actually-served bundle bytes, verifies the deploy-provider binary provenance and the pool/epoch, and **compares all of them to the manifest — any mismatch fails the run** (so a green run cannot claim manifest X while exercising deployment Y) | -| **Output** | **OIDC-backed signed provenance** embedding the verified (release, pool, deployment epoch), not a caller-supplied echo | -| Topologies | one per A.2 flow; publisher-overlap; disabled-initial-load formation; same-class supersession | -| Browsers | Chromium, Firefox, WebKit (CSP/opaque rows); Chromium (funnel rows) | -| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | -| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | -| Command | `npx playwright test --config real-gam.config.ts` | -| Artifact | Playwright HTML report + trace zips, retained 90 days | -| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | -| Approval evidence | green attested run URL in the release checklist, signed off by RO | +- Kernel imports no adapter, service, or integration. +- Adapters import kernel contracts only and are the sole readers/writers of GPT, + Prebid, and cross-window messaging globals. +- Services import kernel and adapter interfaces. +- Integrations compose services and never import another integration. +- Composition imports all layers, constructs one `RuntimeSession`, and injects + adapters/services through interfaces. Kernel sessions never import or construct a + concrete adapter or service. +- Layering is enforced by ESLint restricted paths. +- A custom scope-aware lint rule rejects GPT/Prebid global access outside adapters, + including same-file aliases of `window`, `globalThis`, `self`, `googletag`, or + `pbjs`. + +### 5.2 One runtime across IIFE bundles + +Each shipped integration remains a separately built IIFE with imports inlined, so +module singletons cannot be the shared-runtime mechanism. `tsjs-core` installs the +only runtime and keeps its service registry in the composition closure. During boot, +the temporary `_registerIntegration` handshake collects each accepted module; the +composition root alone invokes its exact frozen preparation/activation contexts. +After commit the handshake is permanently refusing and `tsjs._internal` exposes only +the frozen status described in §5.4. + +Every other bundle registers through: + +```ts +tsjs._registerIntegration({ id, release, prepare }) +``` + +This is a release-internal bundle handshake, not a publisher extension API. An +**integration** remains the product capability; an **integration module** is only +that integration's transactional TSJS implementation unit. The design introduces +no separately installed or third-party plugin system. + +The build first emits every production bundle with the same fixed release sentinel, +then computes one `releaseId`: 64 lowercase hexadecimal SHA-256 characters over a +canonical ordered manifest containing every bundle id and its sentinel-normalized +bytes. It replaces exactly one sentinel in each bundle and verifies none remains. +This avoids a self-referential hash while changing the id for any logical bundle or +ordering change. The same value is embedded in core and every integration bundle. +Before core is injected, the server emits this exact manifest: + +```ts +interface BootManifestV1 { + readonly version: 1 + readonly releaseId: string + readonly integrations: readonly { + readonly id: string + readonly required: true + }[] +} +``` + +Integration ids match `^[a-z0-9][a-z0-9_-]{0,63}$`, are unique, and appear in the actual +server injection order. The list contains exactly the enabled integration bundles; +all listed integrations are required for that page and there are at most 16. Core is injected first, then +integration modules in manifest order. Registration requires exact id membership and `release` +equality. Duplicate id, unknown id, wrong release, missing module, or registration +after the boot deadline fails with `abi_mismatch` or `bundle_partial`. +Integrations obtain stateful services from the registry; they never construct a +second runtime, slot registry, GPT adapter, or bridge listener. + +Integration-module startup is a two-phase transaction. Registration stores code but +does not execute it. In manifest order, core calls a synchronous or asynchronous +`prepare(ctx)` whose only legal effects are validating frozen configuration, +obtaining injected service interfaces, allocating private inert data/closures, and +registering private-memory disposers. Preparation cannot read or write ad-tech +globals, attach a listener/observer/wrapper, touch live DOM, inject a script, start a +timer/fetch, schedule detached work, invoke publisher code, or call a stateful +adapter/service method. The one Promise returned to and awaited by core, including +its ordinary `await`/settlement continuations, is permitted; no continuation may be +detached from that Promise or survive its settlement/abort. Preparation returns +exactly one prepared module with a synchronous `activate(ctx)` function. + +After every required module prepares, core enters one synchronous activation barrier +in manifest order. `activate(ctx)` may install only synchronously compare-restorable +wrappers, listeners, observers, guards, and service subscriptions. It registers the +disposer before each mutation and may stage bounded post-commit work through +`ctx.afterCommit(fn)`, but cannot inject/load a script, start network/timers, schedule +work, drain a publisher queue, or invoke publisher callbacks directly. The core +dispatcher and correctness-critical GPT listeners are the first reversible core +activations in this same barrier, not effects left live during asynchronous +preparation. If any activation throws, core synchronously runs every activated and +prepared disposer once in reverse order before committing fallback. Since the +barrier never yields and activation cannot call publisher code, no publisher task +can observe a partial generation. + +The activation barrier checks the same monotonic boot deadline immediately before +and after every `activate` call and once more before kernel handoff. Elapsed time +greater than or equal to 10,000 ms synchronously unwinds and commits fallback even +when the timer task has not run. JavaScript cannot preempt an activation function +that never returns; a malicious/nonreturning same-realm module can freeze the page +and is an accepted platform limitation, not a second-runtime recovery case. + +After all activations succeed, core commits the complete kernel API, runs staged +`afterCommit` callbacks in manifest order, and only then drains the preload queue. +Those callbacks may synchronously start scripts, timers, readiness work, and baseline +DOM scans; publisher code they intentionally invoke therefore sees the complete +kernel. A callback throw is isolated to its module, runs that module's remaining +disposers, records a bounded local runtime failure, and makes affected operations +fail through their typed readiness/render result; it cannot roll back an already +published kernel or create a fallback generation. + +`ctx.signal` aborts pending preparation. `ctx.onDispose(fn)` is the only disposal +registration mechanism; a disposer registered after disposal runs immediately, and +one failing disposer does not prevent the rest. Each module may call +`ctx.afterCommit` at most once, so the at-most-16 pending modules stage at most 16 +callbacks. A second call by one module throws during activation, unwinds the barrier, +and commits `bundle_partial`. The bootstrap deadline below is the only preparation +deadline; modules share its remaining budget and do not start independent ten-second +preparation clocks. + +### 5.3 Bootstrap ownership + +Bootstrap uses a generation-scoped state machine: + +```text +unclaimed -> installing -> kernel + \-> failed -> fallback +``` + +Initial namespace capture is field-wise and does not replace a publisher-created +`window.tsjs` object: `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}`. The +kernel remains externally inert and commits ownership only after all manifest +integration modules prepare and synchronously activate in order. Before module +work, bootstrap normalizes `que` to one actual Array and defines the `tsjs.que` data +property as writable false/configurable true for the installing generation. It keeps +the ingress Array's native `push` +throughout `installing`. Thus ordinary assignment cannot redirect the queue, and +callbacks pushed at any point during the shared deadline append to the same ingress +Array instead of a one-time snapshot. A preexisting non-Array `que` contributes no +callbacks and is replaced; an Array's existing own data entries are retained in +index order. + +Kernel and fallback use the same synchronous, non-interleavable commit handoff in one +JavaScript task. Its order is exact: + +1. create an empty actual-Array final executor, install its own immediate-execution + `push`, and freeze the Array; +2. snapshot the ingress Array's callable own data entries in ascending index order; +3. clear the ingress Array and replace its `push` with a forwarder to the final + executor for publishers retaining the old reference; +4. redefine `tsjs.que` as the final executor with a writable-false, + configurable-false descriptor while installing all other complete committed + `tsjs` fields; +5. for a kernel commit, run every staged `afterCommit` callback in manifest order; + fallback has none; and +6. drain the snapshot FIFO. + +No browser task or microtask can interleave steps 1–6. Code intentionally invoked by +an `afterCommit` callback sees the complete API and committed queue. A callback is therefore either +in the snapshot or reaches the final executor through one of the two queue +references, never lost or invoked twice. A callback that pushes while the snapshot +drains executes immediately through the committed queue before draining continues; +one throw is isolated. Both ingress and final values satisfy +`Array.isArray(...) === true`. The old ingress identity remains a live forwarding +queue; the public `tsjs.que` identity changes exactly once at commit. + +One ten-second watchdog begins immediately before core injection and covers core, +registration, preparation, and the synchronous activation barrier for every required +integration module. A preparation throw/rejection, activation throw, ABI mismatch, +or deadline aborts the installing generation, synchronously unwinds registered +disposers in reverse order, and then commits the generated no-bundle fallback. +Bundles that arrive after +fallback are rejected and quarantined; they cannot register into or replace the +fallback generation. Late continuations verify their owner generation and +self-discard. + +`gpt_bootstrap.js` becomes a queue-and-boot-data stub. The old bootstrap's +initial-load hooks, handoff wrappers, hydration scheduler, slot definition, +targeting, display, and refresh are deleted. Those behaviors run only after the +complete runtime commits. This intentionally changes the missing/partial-bundle +case: it no longer attempts a best-effort GPT render through a duplicated degraded +runtime, and instead settles every known slot through the terminal fallback below. +The fallback is generated from one TypeScript source and pinned by a staleness test; +behavior is not hand-maintained in both ES5 and TypeScript. + +The fallback is a terminal, non-rendering shell, not a reduced second runtime. Its +commit atomically records one immutable boot failure reason: + +- `abi_mismatch` for invalid manifest shape, duplicate/unknown integration id, wrong + release, duplicate registration, or incompatible ABI; or +- `bundle_partial` for a missing required integration module, preparation + throw/rejection, activation throw, or the shared boot deadline. + +Before draining user work it installs `version:'1.0.0'`, the embedded `releaseId`, a +safe frozen `TsjsBootV1`, the final `tsjs.requestAds` input validator, the +validating-then-refusing `tsjs.addAdUnits`, the local `tsjs.log`, the +immediate-executor `tsjs.que`, a permanently refusing internal +`_registerIntegration`, and a frozen +`tsjs._internal` value containing only `{state:'fallback',releaseId,reason}`. It +constructs no runtime session, slot registry, GPT/Prebid adapter, bridge dispatcher, +timer, listener, port, or iframe. It never exposes a compatibility API. + +The safe fallback boot uses the embedded release and +`manifest:{version:1,releaseId,integrations:[]}`, independently retains the server +auction projection only when that projection passes its exact shape/256-slot bounds, +field grammars, render limits, and 8 MiB aggregate cap from §§3.1–3.2, and otherwise substitutes exactly +`{version:1,auction:{version:1,auctionId:'fallback',results:[]},bids:[]}`. It +retains a valid cache policy or omits it, and substitutes the creative/diagnostics +disabled safe defaults from §§5.4/5.8 because no integration module commits. It never copies an accessor or +unknown property. Fallback batch membership comes only from exact server slot ids in +that validated immutable `tsjs.boot.auctionProjection` snapshot. Explicit valid ids present in that snapshot, +and every omitted-slot snapshot entry in projection order, resolve once as +`failed{path:'primary',reason:}`. An explicit id absent from the +projection resolves `slot_unresolved`; an already-aborted signal resolves each known +member as `cancelled{reason:'caller_aborted'}`. An empty projection plus omitted slots +resolves `{slots:[]}`. Input-shape errors still reject with `RequestAdsInputError`. + +After installing those surfaces, fallback drains the preexisting callback queue FIFO +exactly once with `this === tsjs`; one callback throw does not prevent later callbacks. +Subsequent `que.push(fn)` executes a callable immediately once and ignores non-callable +values. Every late module registration is refused without invoking integration code, and +every late bundle continuation self-discards. Browser tests cover each failure +checkpoint, queued and later `requestAds`, callback throws, already-aborted signals, +and late bundles; no valid call remains pending. + +### 5.4 Public surface after cutover + +There are no compatibility aliases: + +| Baseline surface | Final surface | +| ---------------------------------------- | ------------------------------------------------------------------------------- | +| scattered `window.__tsjs_*` flags/config | `tsjs.boot.*` | +| `tsjs.adSlots`/`tsjs.bids` | initial `tsjs.boot.auctionProjection`; internal navigation projection after SPA | +| `tsjs.version === '0.1.0'` | `tsjs.version === '1.0.0'` plus `tsjs.releaseId` | +| `globalThis.tscreative` | no callable equivalent; automatic creative module | +| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | +| void/callback `requestAds` | `tsjs.requestAds(options): Promise` | +| placeholder `renderAdUnit` | `tsjs.requestAds({slots:[id]})` | +| placeholder `renderAllAdUnits` | `tsjs.requestAds()` | +| generic mutable `setConfig`/`getConfig` | immutable `tsjs.boot.*` plus typed integration config | +| `tsjs.renders`/`renderLog`/`renderSeq` | `tsjs.diagnostics.renderTrace` | +| `window` event `tsjs:adRendered` | `tsjs.diagnostics.renderTrace.subscribe(listener)` | +| `tsjs.gptDiagnostics` | `tsjs.diagnostics.gpt` | +| `window.__tsjs_prebid_bundle` | exact own `pbjs.__trustedServerArtifactV1` stamp | +| integration install/patch sentinels | kernel integration registry/`WeakSet` | +| GPT slot expandos | `SlotRecord` | + +`window.tsjs.que` remains the pre-load command queue because it is the bootstrap +transport, not a legacy behavior alias. + +The complete committed public API is the following union; the pre-load +`{que,boot}` transport is not a committed API generation: + +```ts +interface CreativeBootV1 { + readonly version: 1 + readonly enabled: boolean + readonly clickGuard: boolean + readonly renderGuard: boolean +} + +interface TsjsBootV1 { + readonly abi: 1 + readonly releaseId: string + readonly manifest: Readonly + readonly auctionProjection: Readonly + readonly cachePolicy?: Readonly + readonly creative: Readonly + readonly diagnostics: Readonly +} + +interface TsjsCommandQueue { + readonly length: 0 + push(callback: unknown): 0 +} + +interface TsjsApiBase { + readonly version: '1.0.0' + readonly releaseId: string + readonly boot: Readonly + readonly que: TsjsCommandQueue + readonly log: TsjsLog + /** Release-internal late-bundle sink; always returns false after commit. */ + readonly _registerIntegration: (registration: unknown) => false + addAdUnits( + units: ProgrammaticAdUnit | readonly ProgrammaticAdUnit[] + ): AddAdUnitsResult + requestAds(options?: RequestAdsOptions): Promise +} + +interface TsjsKernelApi extends TsjsApiBase { + readonly diagnostics: Readonly + readonly _internal: Readonly<{ state: 'kernel'; releaseId: string }> +} + +interface TsjsFallbackApi extends TsjsApiBase { + readonly diagnostics?: never + readonly _internal: Readonly<{ + state: 'fallback' + releaseId: string + reason: 'abi_mismatch' | 'bundle_partial' + }> +} + +type TsjsApi = TsjsKernelApi | TsjsFallbackApi +``` + +`version` is the semantic public-API generation and changes only with a reviewed API +contract; `releaseId` identifies the exact bundle set and equals +`boot.releaseId`/`boot.manifest.releaseId`. Core recursively freezes the boot value +before installing integrations. Integration-specific configuration is not a public +mutable bag: the composition root validates each server-projected, deny-unknown +config against that integration's typed schema and passes the frozen value only in +its preparation/activation contexts. `_internal` is a frozen, non-enumerable status +value; the service registry remains in the composition closure and is available to +integration modules only through those contexts during startup. + +The final queue is the frozen actual empty Array from the commit handoff and contains +no retained callbacks. Its own `push` invokes one callable +immediately and exactly once with `this === tsjs`, returns `0`, ignores a +non-callable, and isolates/logs a throw. Pre-load callbacks are snapshotted and +drained FIFO only after the committed API is installed, so publisher callbacks never +run against a half-installed generation. Native mutators, borrowed Array mutators, +index assignment, `length` assignment, deletion, and property definition cannot +change the frozen executor or retain a callback; failure follows ordinary strict- or +sloppy-mode JavaScript semantics and `length` remains `0`. + +#### 5.4.1 Programmatic ad-unit registration + +The clean public core surface retains programmatic direct-auction registration: + +```ts +interface ProgrammaticAdUnit { + code: string + mediaTypes: { + banner: { sizes: readonly (readonly [number, number])[] } + } + bids?: readonly { + bidder: string + params?: Readonly> + }[] +} + +interface AddAdUnitsResult { + readonly registered: readonly string[] +} + +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' + +class AdUnitRegistrationError extends Error { + readonly code: AdUnitRegistrationErrorCode + readonly unitIndex?: number +} + +class TsjsUnavailableError extends Error { + readonly code: 'runtime_unavailable' + readonly releaseId: string + readonly reason: 'abi_mismatch' | 'bundle_partial' +} +``` + +Registration is synchronous, all-or-nothing, and navigation-scoped. `code` becomes +the exact slot id and must satisfy the server slot-id UTF-8 bound from §4.6. The +argument is one unit or a nonempty array of at most 256 plain data objects. Codes are +nonempty and unique in the call; banner sizes are nonempty integral number pairs in +the shared 1–4096 renderer range; bidder names are nonempty and at most 64 UTF-8 +bytes; params are plain JSON-compatible data with the same request-body cap as +`/auction`. Accessors, +unknown media types, duplicate codes, or a collision with any server-projected or +already registered slot reject the whole call with a typed +`AdUnitRegistrationError` before state changes. There is no merge-by-code behavior. +The outer shape/count maps to `invalid_units`; a non-plain unit or unknown/accessor +unit field to `invalid_unit`; code shape, in-call duplicate, and registry collision to +`invalid_code`, `duplicate_code`, and `slot_collision`; media/banner shape to +`invalid_media_types`; a nonnumeric/nonfinite/fractional/nonpositive dimension to +`invalid_dimensions`; an integral dimension outside 1–4096 to +`dimensions_out_of_range`; bid-array and bidder-name shape to +`invalid_bids`/`invalid_bidder`; non-JSON, cyclic, accessor-bearing, or otherwise +unserializable params to `invalid_params`; and encoded body overflow to +`request_body_too_large`. `unitIndex` is the lowest failing input index when the +failure belongs to a unit and is absent for outer/capacity errors. Validation order +is the order just listed, then capacity reservation; repeated runs return the same +code/index without reading publisher accessors. + +Successful units receive registration ordinals after the immutable server projection +and participate in later `requestAds` snapshots. They use the direct auction/render +path unless an explicit future design gives them a GPT mapping; registration alone +never defines, displays, refreshes, or targets GPT. The old placeholder-writing +methods are deleted rather than aliased. `tsjs.log` retains the existing bounded +level/method surface, while runtime configuration is immutable boot data or typed +integration-owned configuration. Fallback validates `addAdUnits` input and then throws +`TsjsUnavailableError` with the committed boot failure; it constructs no registry. + +The retained logger has this exact hard-cutover surface: + +```ts +type TsjsLogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug' + +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 +} +``` + +The initial level is `warn`. `setLevel` accepts only the five exact strings above; +an invalid runtime value throws `TypeError` without changing the current level. +Missing/throwing console methods are swallowed at the logger boundary, log failures +never change ad behavior, and the logger does not retain argument arrays. The +fallback exposes the same logger and level behavior. + +### 5.5 Direct auction API + +```ts +interface RequestAdsOptions { + slots?: readonly string[] + timeoutMs?: number + signal?: AbortSignal +} + +type RequestAdsInputErrorCode = + | 'invalid_options' + | 'invalid_slots' + | 'empty_slots' + | 'duplicate_slot' + | 'invalid_timeout' + | 'invalid_signal' + +class RequestAdsInputError extends Error { + readonly code: RequestAdsInputErrorCode +} + +type RequestAdsSlotResult = + | { slot: string; path: 'primary' | 'fallback'; outcome: 'accepted' } + | { slot: string; path: 'primary' | 'fallback'; outcome: 'no_bid' } + | { + slot: string + path: 'primary' | 'fallback' + outcome: 'failed' + reason: RenderFailureReason + } + | { + slot: string + path: 'primary' | 'fallback' + outcome: 'cancelled' + reason: 'caller_aborted' | 'superseded' | 'navigation_disposed' + } + +interface RequestAdsResult { + slots: RequestAdsSlotResult[] +} +``` + +Every `RequestAdsOptions.slots` entry is an exact, case-sensitive registered slot id: +either a server slot id from §2.2 or a programmatic `code` admitted by §5.4.1. The +public API never interprets an entry as a GPT ad-unit path, DOM id, or DOM alias. An +explicit id absent from the invocation snapshot resolves individually as +`failed{reason:'slot_unresolved'}` while valid siblings proceed. Internal GPT-path or +DOM-alias lookup that has zero or multiple matches also fails the affected slot as +`slot_unresolved`; it never selects the first registration. + +When `slots` is omitted, `requestAds` synchronously snapshots every server-projected +and programmatic slot registered in the current `NavigationSession`, ordered by its +navigation-local registration ordinal. That immutable snapshot is the batch +membership and result order; a slot registered after invocation is excluded. When +`slots` is present, its validated input order is the result order. The `slot` field in +every result is always the exact registered slot id. + +When `timeoutMs` is omitted, the shared auction-response deadline is exactly 10,000 +milliseconds. An explicit value replaces that default and must satisfy the bounds +below; renderer/GPT path deadlines remain independently fixed by their lifecycle +transitions. + +Omitted/`undefined` options are valid. Otherwise the argument must be a non-null +plain object whose prototype is this realm's `Object.prototype` or `null`, containing +only `slots`, `timeoutMs`, and `signal` as own enumerable data properties. Accessors +or unknown keys are `invalid_options`; non-array slots, non-string/empty/>256-byte +slot values, and more than 256 values are `invalid_slots`; an explicitly empty array +is `empty_slots`; an exact duplicate is `duplicate_slot`; a noninteger timeout +outside `100..30_000` is `invalid_timeout`; and a value that fails the platform +`AbortSignal.prototype.aborted` brand getter is `invalid_signal`. These reject +before attempts with `RequestAdsInputError`. Once an attempt is created, the +returned promise resolves with per-slot terminal results and does not reject for +auction or render failures. Unknown requested slots fail individually while valid +siblings proceed. Omitted slots with an empty registry resolve an empty `slots` +array. A registration collision cannot alter the snapshot or result ordering. + +The response deadline governs only the shared auction fetch. Once a response parses, +path-specific renderer deadlines take over. The caller signal remains active until +all child attempts settle. + +### 5.6 Adapters and external readiness + +GPT and Prebid adapters expose `present | pending | timed_out | incompatible`. +`timed_out` and `incompatible` are not permanent global failures: a later valid +external replacement can satisfy later operations. +Each queued operation owns its own deadline and disposal; an expired operation is +removed instead of running unexpectedly when the external library appears later. +Each adapter queue holds at most 64 live operations, drains FIFO, and fails only the +overflowing operation with `external_queue_full`. The operation deadline is exactly +ten seconds from enqueue and is independent of the auction-fetch response deadline; +expiry is `external_ready_timeout`. Readiness at the boundary races through the +operation's terminal latch, so exactly one of dispatch or timeout wins. + +The GPT adapter owns early event subscription, command-queue interaction, +`display`, `refresh`, targeting, and service-state inspection. The Prebid adapter +owns commands, event subscription, bid-response registration, and Universal +Creative integration. Tests use adapter fakes rather than mutable global objects. + +The decoupled Prebid artifact remains pure Prebid.js and retains its independent +five-second queue-drain watchdog. The generated wrapper arms that watchdog as its +first statement, before stamp inspection or module initialization. At 5,000 ms it +looks up the then-current real Prebid object and calls its idempotent `processQueue()` +at most once for that wrapper, whether or not the TS integration installed, so +publisher callbacks are not held hostage by stamp conflict, a missing TS bundle, or +a partial/duplicate artifact. Duplicate wrappers may reach the idempotent API, but +black-box tests require each queued publisher callback to execute once. A later TS integration module may call the idempotent API again. +The module first verifies the real Prebid API, then installs transactionally. It does +not install the synthetic-refresh policy, clear targeting, or mutate publisher bids +when only the injected `{que,cmd}` stub exists. The artifact manifest carries both +module stems and runtime bidder codes, including aliases. + +The artifact exposes this frozen plain-data runtime stamp; the separately emitted +build manifest contains the same fields plus filename/integrity metadata: + +```ts +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 + readonly moduleStem: string + }[] + readonly userIdModules: readonly { + readonly moduleName: string + readonly configNames: readonly string[] + readonly eidSources: readonly string[] + }[] +} +``` + +After arming the watchdog and before executing embedded Prebid module factories, the +wrapper inspects the current `window.pbjs` and its own descriptor for +`__trustedServerArtifactV1`. If that object already has the required real API plus a +valid recursively frozen stamp, an exact same-release/content duplicate reuses it and +skips module initialization/redefinition; a different valid release refuses only the +new wrapper and likewise leaves the already-working object untouched. A different +artifact can become active only by replacing the whole `window.pbjs` object. + +Otherwise the wrapper initializes its embedded pure Prebid modules. It then attempts +to define one own non-enumerable, non-writable, non-configurable data property whose +value is the recursively frozen `ExternalPrebidArtifactV1` only when the descriptor +is absent. Define failure is caught locally. An accessor, inherited-only value, +invalid stamp, or hostile/different non-configurable value is left untouched and +records at most one bounded console warning when possible. None of these paths +throws, cancels the already-armed watchdog, or prevents publisher Prebid from +initializing; they make only TS readiness incompatible. + +This inert build description is the artifact's only Trusted Server handshake. Apart +from the independent Prebid queue self-start watchdog specified above, the generated +wrapper performs no auction, admission, render, targeting, or refresh behavior. The +Prebid adapter reads the property only through `Object.getOwnPropertyDescriptor`, +rejects an accessor or inherited value, and captures both the `pbjs` object and stamp +identities in one `PrebidArtifactBinding`. Every operation rechecks both identities; +replacement of `window.pbjs` invalidates only that binding and later readiness may +bind the new object if it carries a valid stamp. No `window.__tsjs_*` stamp or +fallback lookup exists. + +The artifact build contains exactly one 64-zero-character release sentinel in that +runtime stamp. It hashes the emitted JavaScript after normalizing that one field back +to the sentinel, writes the resulting 64 lowercase hexadecimal SHA-256 characters +into the field, and verifies that no sentinel remains. The separately emitted build +manifest records that same `artifactReleaseId` plus the ordinary SHA-256/SRI of the +final bytes. Thus the embedded id has a non-self-referential preimage; it is +diagnostic artifact identity, not a requirement to match the TSJS `releaseId`. + +`prebidVersion` must be exactly `10.26.0`; changing it requires the reviewed +artifact-contract fixture update in §3.5. Module/code/config names are nonempty, +unique in their array, and at most 128 +UTF-8 bytes; EID sources are lowercase, nonempty, unique per module, and at most 256 +UTF-8 bytes. The manifest admits at most 256 module stems, 512 bidder codes, 512 +alias rows, and 128 user-ID modules with at most 64 config names and 64 EID sources +each. Every alias code appears in `bidderCodes`, every alias module appears in +`moduleStems`, and each configured `client_side_bidder` must appear in +`bidderCodes`. Arrays are lexically sorted so build/runtime fixtures compare exact +content. + +A missing stamp, wrong `abi`, invalid release/version, malformed/oversized member, +missing configured bidder, missing required user-ID module/EID mapping, or a real API +missing a required method makes the current readiness operation +`external_artifact_incompatible`. It records one bounded local diagnostic and does +not install TS refresh interception or mutate publisher state. An older unstamped +artifact remains ordinary publisher Prebid: its own 5,000 ms watchdog drains its +queue exactly once, and the later TS module does not replay publisher callbacks. +Replacement by a valid artifact can satisfy later operations. Compatibility requires +both `abi:1` and exact Prebid 10.26.0; external artifacts are not pinned to a TSJS +release id. + +### 5.7 GPT correctness retained during decomposition + +- Subscribe to `slotRequested` and `slotRenderEnded` before any TS request. +- Pass `changeCorrelator: false` for TS refreshes unless the explicit configuration + says otherwise. +- Call `enableSingleRequest()` only before services are enabled; never reconfigure a + publisher-owned GPT service after `enableServices()`. +- Restore the intended initial-load behavior represented by issue #922/PR #997 and + pin it with tests. +- Responsive-size ambiguity fails `slot_unresolved` and never silently skips or + chooses an arbitrary container. +- A TS fallback slot is defined on the resolved inner div, never its outer + `-container`. An exact later publisher `defineSlot` receives that same live slot + even when its path/formats differ, with a local mismatch warning, because defining + a second physical slot would violate the one-placement invariant. A + hydration-renamed alias is accepted only when the original element is gone and + exactly one live, unclaimed TS fallback shares the configured prefix, exact GAM + path, and normalized formats. Ambiguity remains native and cannot transfer TS + ownership. +- Successful handoff synchronously transfers ownership, removes the slot from the + TS destroy set, suppresses exactly the publisher's duplicate initial `display`, + and under disabled initial load suppresses exactly its duplicate first refresh. + A global refresh expands the live GPT slot list, filters only one-shot suppressed + slots, and forwards unrelated slots with the original options. +- Publisher calls are not held until TS targeting is ready. A publisher-owned + display/refresh remains publisher work, and its failures cannot start TS fallback. +- Every TS-owned GPT destroy/redefine uses one adapter transaction. It first marks + the exact old object and cycle retired, then calls `destroySlots([old])` and + requires a successful return before attempting `defineSlot` for a replacement. + A throw/false destroy leaves the old identity retired and its path/aliases + quarantined, defines no second physical slot, and makes current or next TS work + fail `gpt_request_failed` until publisher destruction or reload. If destroy + succeeds but replacement definition fails, the slot stays unbound and the same + failure is returned; bindings and ownership commit only after one replacement is + successfully defined. A stale generation after either call disposes any newly + created TS-owned replacement and cannot bind it. Request-timeout, completion- + timeout recovery, navigation replacement, and DOM reconciliation all call this + transaction rather than open-coding destroy/redefine. +- Runtime-owned DOM reconciliation detects when a framework replaces the element of + a TS-owned live slot. It debounces changes, retires/destroys only the orphaned + TS-owned GPT object, resolves the unique current element, and rebinds within the + current navigation. One `MutationObserver` per `NavigationSession` watches + `childList` changes under `document.documentElement`; it is disconnected on + navigation disposal. Once an exact owned element becomes disconnected, that slot + opens a 5,000 ms reconciliation window on the monotonic clock. The first resolution + pass runs after 250 ms without another relevant mutation; if it is unresolved or + ambiguous, exactly one final pass runs at the 5,000 ms boundary. A pass that finds + one unique current element may win the slot's terminal reconciliation latch only + after the destroy/redefine transaction commits its replacement. Destroy or define + failure settles current work as `gpt_request_failed`; it is not counted as a + successful rebind. The window expiry racing a successful final pass goes through + the same latch: success wins only if the unique replacement was committed first; + otherwise the slot records `slot_unresolved` and runs the failed-reconciliation + disposer. That disposer + cancels the slot's active TS request cycle, tombstones its live render reservation, + compare-restores only targeting still equal to TS-installed values, clears every + exact/alias binding to the orphan, and asks the GPT adapter to destroy that exact + still-TS-owned object. Before the destroy call it marks the object/cycle retired in + weak identity state, so a throw or later GPT callback is quarantined and cannot + re-enter selection, fallback, trace attribution, or targeting. It then releases + the timer, candidate set, and all strong references. Successful destruction + settles a nonterminal current attempt as `failed{reason:'slot_unresolved'}`; + throw/false destruction settles it as `failed{reason:'gpt_request_failed'}` and + adds one bounded local warning. Neither outcome restores ownership. + + A second disconnect after one successful rebind may open one final window; two + successful rebinds is the per-slot, per-navigation maximum. A further disconnect + immediately runs that same disposer with + `failed{reason:'reconciliation_capacity'}` and cannot rebind again before the next + `NavigationSession`. Navigation disposal uses the same physical-object/targeting/ + cycle cleanup without emitting a new failure. Reconciliation never destroys a + transferred or otherwise publisher-owned slot, and ownership transfer racing any + pass wins the latch, cancels TS reconciliation state, removes TS destroy ownership, + and leaves physical/targeting cleanup to the publisher. + +- The Prebid refresh policy preserves the exact `excluded_gam_ad_unit_path_suffixes` + behavior: path matching is literal/case-sensitive suffix matching; missing, + non-string, or throwing `getAdUnitPath()` fails open; stale TS/Prebid keys are + cleared from every target; only eligible slots enter the synthetic auction; and + the complete target slot list plus original options still reaches GPT. +- Use one adapter-level refresh interception and one slot-service request path; + remove the three independent integration wrappers without removing handoff or + exclusion semantics. +- Preserve the baseline collapsed-shell resize as a guarded exception tied to the + current attempt. It runs only after a TS PUC response is posted and only when the + source is the exact connected iframe, width/height attributes and computed size + are still at most one pixel, dimensions are finite/positive, the frame/wrapper are + ordinary non-fixed/non-sticky display shells, and no anchor container is present. + Only that iframe and its still-collapsed immediate wrapper may be resized. + +### 5.8 Local diagnostics + +The kernel owns a bounded, failure-isolated diagnostics bus. Render attempts and the +GPT adapter publish immutable observations after their correctness transition; a +diagnostics subscriber can never delay, reject, retry, or mutate the source +operation. Subscriber throws are logged locally and isolated from later subscribers. +The internal bus admits at most 16 integration-module subscriptions, one for each +manifest member; publisher code cannot register on that internal bus. + +`tsjs.diagnostics.renderTrace` replaces the mutable `tsjs.renders`, `renderLog`, and +`renderSeq` globals and the `tsjs:adRendered` CustomEvent with read-only snapshot and +subscription methods. The final schema is: + +```ts +type RenderTracePathV1 = 'auction' | 'ssat' | 'gam-refresh' +type RenderTraceServedFromV1 = + | 'inline' + | 'gam' + | 'debug-adm' + | 'pbs-cache' + | 'prebid' + +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 +} + +interface RenderTraceDiagnostics { + current(): Readonly>> + history(): readonly Readonly[] + subscribe(listener: (record: Readonly) => void): () => void +} + +interface TsjsDiagnostics { + readonly renderTrace: RenderTraceDiagnostics + readonly gpt?: GptDiagnosticsApi +} + +class DiagnosticsSubscriberLimitError extends Error { + readonly code: 'subscriber_capacity' + readonly surface: 'renderTrace' | 'gpt' +} +``` + +Snapshots are frozen copies, not references to the runtime store. Subscription is +FIFO; unsubscribe is idempotent; a listener registered during dispatch begins with +the next observation. `count` is the positive per-slot impression ordinal, `seq` is +the positive runtime-global observation ordinal, and `at` is the initial record's +`Date.now()` epoch milliseconds; enrichment retains all three. The initial record and +every later enrichment commit to `current`/`history` first and return to the +correctness/publisher stack without invoking public code. After commit, the +diagnostics service snapshots the current subscriber ids and enqueues one frozen +full-record copy in a 200-entry FIFO keyed by `seq`; another enrichment pending for +that `seq` replaces both its queued snapshot and captured subscriber-id set without +changing order. A listener added after the initial commit may therefore receive a +later enrichment commit, but never the earlier snapshot. Overflow drops the oldest +pending notification and increments a diagnostics-only counter. One owned +zero-delay task drains the queue in observation order. A listener registered after a +commit cannot receive that committed observation; unsubscribe before delivery +suppresses its captured id; registration during delivery starts with the next +observation. A slow/non-returning listener can block only that later diagnostics task, +never the render/GPT transition that scheduled it, and a throw is isolated from later +listeners. This asynchronous frozen delivery is the timing/detail replacement +contract for the removed `tsjs:adRendered` event; no CustomEvent or compatibility +alias is emitted. + +Each public diagnostics surface admits at most 32 live subscribers. A 33rd +subscription throws `DiagnosticsSubscriberLimitError{code:'subscriber_capacity'}` +without adding the listener; unsubscribe immediately returns capacity. The +argument must be callable or `subscribe` throws `TypeError` before the capacity +check. The +composition root freezes `tsjs.diagnostics` only after all manifest diagnostics +integration modules have registered their surfaces. Fallback exposes no diagnostics +namespace because it constructs no runtime. + +- sequence numbers are runtime-global across separately built IIFEs; +- current state is keyed by exact registered slot id and therefore capped by the + 256-record navigation registry. Slot/navigation disposal synchronously prunes its + current entry; history is document-runtime scoped, capped at 200, and evicts the + oldest row before append; +- one physical impression is one history row; a later bridge/GPT/visibility signal + enriches that row in place and cannot weaken prior `rendered` or `injected` truth; +- a publisher/GAM refresh with no current TS auction has no TS attribution; +- `gam-only` means GAM reported fill without proof TS placed the creative, while + `ok` requires TS placement plus visibility; +- DOM `data-ts-*` stamps remove absent/stale fields on every update; an old badge is + removed before the new status is considered; and +- the boot-armed local overlay remains bounded, newest-first, click-to-export, + and noninteractive with the creative. Overlay/export failure cannot affect ads. + +Diagnostics enablement is resolved before core preparation and transported only +through frozen boot data: + +```ts +interface DiagnosticsBootV1 { + readonly version: 1 + readonly renderTraceOverlay: boolean + readonly gpt: { readonly active: boolean } +} +``` + +The server always emits this complete value, defaulting to +`{version:1,renderTraceOverlay:false,gpt:{active:false}}`. Both objects must be +non-null plain objects with exactly the shown own enumerable data properties; +accessors, unknown/missing keys, or wrong prototypes/literals/types are +`abi_mismatch`, not silent diagnostics disablement. The kernel copies the validated +data and recursively freezes that copy before module preparation; copy/freeze +failure is also `abi_mismatch`. `gpt.active:true` requires exactly one required `gpt_diagnostics` +integration id in `BootManifestV1`, and `false` requires that module to be absent; +the inverse mismatch is also `abi_mismatch` before any GPT diagnostics listener or +buffer exists. + +The existing render-trace server toggle resolves +`tsjs.boot.diagnostics.renderTraceOverlay`; TSJS does not read or mutate its cookie. +GPT diagnostics remains deployment-disabled by default. When configured, one exact +`ts_console=1|true` directive on an eligible GET document navigation enables the +host session and `ts_console=0|false` disables it; values are case-sensitive and +duplicate/unrecognized directives fail closed for that response. The server owns the +host-only HttpOnly session cookie, removes the reserved directive before publisher or +origin handling, preserves unrelated path/query/fragment data, and emits only the +resolved `gpt.active` boolean. The old +`window.__tsjs_gpt_diagnostics_active` flag and browser storage bootstrap are deleted. + +The GPT diagnostics integration module preserves the behavioral contract in +`docs/superpowers/specs/2026-07-28-gpt-runtime-diagnostics-overlay-design.md` unless +this design explicitly changes ownership or activation transport. It consumes raw +facts from the sole GPT adapter rather than registering another control wrapper. + +When `gpt.active` is true, core installs the six documented GPT observations +(`slotRequested`, `slotResponseReceived`, `slotRenderEnded`, `slotOnload`, +`impressionViewable`, and `slotVisibilityChanged`) before any TS-owned GPT request. +It starts a 512-entry FIFO pre-module fact buffer and replays it in order when the +diagnostics module activates. Overflow evicts the oldest fact and increments one +diagnostics-only counter; after replay, live facts fan out directly and the buffer is +released. When inactive, no diagnostics buffer or four diagnostics-only listeners +exist. The GPT adapter may still own `slotRequested` and `slotRenderEnded` listeners +required for ordinary ad correctness under §5.7; inactive zero-side-effect tests +measure that baseline and require zero diagnostics-added listeners, DOM, timers, +observers, API, or network work. + +The GPT diagnostics store retains at most 64 observed GPT slot objects, ten request +cycles per slot, and 128 callback-issue records. It evicts the +least-recently-active slot or oldest cycle/issue before insert and increments the +corresponding export counter. An evicted GPT slot can re-enter only on a future +`slotRequested`; its monotonic request number is retained in a `WeakMap`, and earlier +non-request callbacks remain unmatched. Its public API shares the 32-subscriber cap +above. Exact slot identity/binding and element replacement, physical request cycles, +callback truth, timing fields, frozen bounded export, Shadow DOM overlay, badge +layers, SPA behavior, privacy, and non-interference remain. Diagnostic records are +memory-only; neither diagnostics surface writes localStorage, sessionStorage, +IndexedDB, or uploads data. The hard-cutover API is `tsjs.diagnostics.gpt`; the old +flag/runtime expandos and `tsjs.gptDiagnostics` alias are deleted. + +GPT public subscriptions use a separate one-entry latest-snapshot notifier and never +run from a GPT callback, adapter fan-out, store mutation, or binding observer. After +each committed store/binding change, the controller builds one frozen +`GptDiagnosticsExportV1`, snapshots current subscriber ids, and schedules one owned +zero-delay task. A later change before delivery replaces that pending snapshot and +subscriber-id set; this API signals current state rather than promising one callback +per raw GPT fact. Registration after a commit cannot receive that commit unless a +later change replaces the pending snapshot; unsubscribe before delivery suppresses +the captured id. Listener throws are isolated, and a slow/non-returning listener can +block only the diagnostics task. Module disposal cancels the task, clears the one +pending snapshot and subscriber set, and delivers nothing later. `snapshot()` remains +a synchronous frozen read with no subscriber invocation. Tests apply the same +subscribe/unsubscribe/slow/throw rules as render trace plus 0/1/2-update coalescing. + +### 5.9 Creative and remaining integration preservation + +`CreativeBootV1` in §5.4 is exact plain boot data. The server always emits it. A +disabled integration is exactly +`{version:1,enabled:false,clickGuard:false,renderGuard:false}` and has no `creative` +manifest member. When enabled but its config is absent, the server emits +`{version:1,enabled:true,clickGuard:true,renderGuard:false}`; explicit configuration +replaces the two guard booleans. `enabled:true` requires exactly one required +`creative` manifest id and `false` requires its absence. An accessor, non-plain +prototype, missing/unknown key, wrong literal/version/type, disabled non-false guard, +or manifest mismatch is an `abi_mismatch` before any creative guard installs. The recursively +frozen `tsjs.boot.creative` is the only final inspection/configuration surface. +`globalThis.tscreative`, `globalThis.tsCreativeConfig`, `installGuards`, `setConfig`, +and `getConfig` are deleted, not aliased; changing guard policy requires a new boot/ +document generation. + +The creative integration module prepares inertly and activates transactionally +exactly once in the kernel barrier. Activation installs the click guard when +`clickGuard` is true and the image/iframe dynamic-source guards when `renderGuard` is +true, but performs no baseline DOM rewrite. Only when +`clickGuard || renderGuard` is true, activation gives a still-loading document one +owned `DOMContentLoaded` callback to perform the baseline idempotent rescan after the +initial DOM completes; an already interactive/complete document gets no listener and +performs that scan from one staged `afterCommit` callback. Its +disposer removes that listener, observers, and owned DOM state and compare-restores a +patched constructor/property/function only if the current value is still the exact +wrapper installed by this generation. Disabled guards install no wrapper, observer, +listener, scan, or DOM state, whether the integration itself is disabled or it is +enabled with both guard booleans false. SPA navigation retains the document-scoped +guards and their dynamic-node behavior; failed preparation/activation or full runtime +disposal removes them once. + +Creative processing keeps its current independent policy controls. Auction +sanitization remains explicit opt-in/default-off; rewriting retains its existing +setting/default and still runs on every delivery path where that setting applies. +The runtime click guard resolves and stores one validated absolute HTTP(S) URL before +navigation, rejects `javascript:`, `data:`, `blob:`, malformed, and credentialed +targets, and uses the established `/first-party/proxy-rebuild` GET redirect path to +recover clicks from the opaque sandbox. Dynamic resource/click rewriting, iframe +sandbox attributes, font/CORS handling, body/base handling, and the direct/SSAT/cache +delivery boundaries remain covered by unit plus real-browser tests. This APS/TSJS +work neither enables sanitization nor broadens creative privileges. + +Every other enabled TSJS integration becomes a thin transactional integration module without an +internal feature rewrite. Its complete current unit suite runs unchanged against a +pre-cutover fixture and a module-composed fixture. At minimum the parity corpus +proves: + +| Integration | Required preserved behavior | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DataDome | dynamic script/preload matching and fixed first-party route rewriting preserve the upstream path | +| Didomi | configured proxy path becomes the absolute `didomiConfig.sdkPath` without clobbering unrelated publisher config | +| Google Tag Manager | script/preload rewriting plus Google Analytics `sendBeacon`/`fetch` rewriting preserve method, body, and unrelated traffic | +| Lockr | script guard plus bounded SDK readiness polling rewrites only the initialized Lockr API host | +| Osano | USP/GPP/TCF cookie mirroring retains marker ownership, timeout, readiness, retry, event, focus/visibility, clear, and non-clobber semantics | +| Permutive | script guard, bounded SDK readiness, API-host rewriting, and at-most-100 normalized local segment values continue to feed auction context | +| Sourcepoint | optional SDK guard and Sourcepoint-owned GPP cookie mirroring retain localStorage shapes, marker ownership, initial retry, visibility/focus updates, and safe clearing | +| Testlight | preexisting and later callbacks bridge once into the final TSJS queue; invalid entries and one throwing callback do not block later work | + +The shared script/beacon/DOM-insertion guards keep integration-owned matchers and +routes. A shared helper may centralize interception, but it cannot broaden one +integration's matcher, reorder another integration's startup, stack interception, +or leave a timer/listener after module disposal. Maximal-bundle tests load every +server-declared integration module in real manifest order and assert both its behavior and +exactly-once disposal, not merely successful registration. + +### 5.10 Error handling and bounded state + +No empty `catch` remains in the migrated kernel, adapters, or APS/GPT/Prebid paths. +Boundary failures become typed results and a concise local warning; disposer and +late-callback failures cannot escape into publisher code. Logs redact descriptors, +AAX payloads, account ids, creative URLs, auction bodies, and capability values. + +Every collection has a named owner, capacity, and pruning rule. Tests exercise +capacity, TTL, duplicate registration, replay, timeout, navigation replacement, and +late continuation behavior with fake timers. + +### 5.11 Decomposition targets + +The implementation extracts cohesive behavior rather than mechanically splitting +by line count: + +| Current area | Target responsibility | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `gpt/index.ts` | thin integration composition over GPT adapter, slot service, initial-load policy, handoff, SPA navigation, and render bridge | +| `prebid/index.ts` | thin integration composition over Prebid adapter, shim, refresh handler, eids, and APS registration | +| `core/request.ts` | validation plus `AuctionBatch`; rendering delegated to render service | +| `core/render.ts` | path-independent DOM helpers only; lifecycle lives in render service | +| `core/trace.ts` | diagnostics subscriber over lifecycle/GPT observations; no mutable integration-owned trace registry | +| APS maps in globals | runtime-owned bounded reservation service | +| duplicated `script_guard.ts` | shared factory plus integration configuration | + +Other integrations receive an integration-module wrapper and service lookup where required, but +their internal behavior is not otherwise rewritten. + +### 5.12 TypeScript and performance gates + +The lockfile compiler is the authority. CI runs a checked-in `typecheck` script with +`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, +`verbatimModuleSyntax`, `noImplicitOverride`, and +`useUnknownInCatchVariables`. Production bundles contain no dynamic imports. + +Before implementation, CI records deterministic gzip/Brotli baselines for minimal, +reference, and maximal integration sets; each may grow at most 5% unless separately +approved. Boot-to-first-display p90 uses a pinned Chromium version, CI runner class, +fixture, warmup count, and sample count and must stay within 10% of that pre-change +baseline. Retained heap uses Chromium CDP only, with forced-GC checkpoints after +boot, first render, refresh, and SPA navigation, and the same 10% limit. Correctness +runs independently in Chromium, Firefox, and WebKit. Correctness failures are never +waived by a performance pass. + +## 6. Security and privacy + +1. Renderer iframes omit `allow-same-origin`; cross-origin target `"*"` is permitted + only when transferring a one-use port to an exact, already-checked + `contentWindow`. +2. The initial global PUC request contains the opaque renderer reservation + capability but no descriptor, ADM, lifecycle ticket, or nonce, and it establishes + no success. The first compatible claim acquires the PUC source; render authority + begins only after exact reservation/slot lookup, attributable nonempty GAM, + current generation, source binding, and atomic consumption. +3. Lifecycle tickets and nonces are CSPRNG, one-use, TTL-bounded, never logged, and + invalidated on supersession/navigation. +4. Exact-key message parsing prevents confused-deputy extensions. Unknown versions + are ignored or failed closed according to whether the message claims a TS + capability. +5. Native Prebid messages with non-TS ids continue to native listeners. Any message + carrying a live or tombstoned TS id is suppressed before later validation. +6. The upstream APS runner URL, every creative URL, and production renderer/proxy + routes must be HTTPS. HTTP is permitted only for loopback hermetic adapters; their + fixed local proxy route remains covered by CSP `'self'`. The runner executes only + through the fixed-target, anonymous-CORS Trusted Server proxy. The proxy relays the + APS body unchanged but never stores it in source, forwards publisher credentials, + accepts a caller-selected target, or executes a fallback. Runner-created APS-origin + resources may use their own origin cookies under browser policy. The renderer + document accepts no cookie as authority. +7. Script creatives remain opt-in because they materially broaden executable + behavior. Enabling them requires a documented security review of the fixed + renderer CSP. +8. The design adds no persistent identifier and no external event pipeline. + +## 7. Verification and acceptance + +### 7.1 Required test layers + +| Layer | Required proof | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Rust unit | APS parsing/admission, dimensions, scripts, AAX projection, mediation provenance/order/timeouts, targeting identity, descriptor serialization, endpoint headers/body | +| `rc/july` parity | executable `905984e62` TSJS source/test/browser inventory; every `RCJ-*` row maps to pre-cutover evidence, a final owner, focused tests, and either retained or deliberately replaced observable behavior | +| Cross-language corpus | every positive/adversarial descriptor has the same Rust, TS, and embedded ES5 result; stale generation fails | +| TS unit | kernel ownership, integration-module transaction/unwind, terminal fallback, sessions, registries, selection/cycle/batch/latch APIs, adapter readiness, GPT handoff/reconciliation, Prebid artifact/refresh, creative security, diagnostics, and every remaining integration parity corpus | +| Hermetic browser | all render paths, PUC bridge, three-level APS sizing, direct iframe races, owner/port/runner behavior, fallback, SafeFrame-shaped nesting, GPT handoff/hydration, creative clicks, diagnostics, and duplicate/replay/wrong-source/stale cases | +| Real-GAM test network | SSAT APS-PUC, Prebid-adapter APS-PUC, page-bids APS-PUC, direct APS, direct ADM/cache, fallback after attributable empty GAM, SRA, refresh, SPA, handoff, hydrated DOM replacement, and collapsed shell | +| Adapter parity | exact renderer sandbox/CSP/header bytes plus runner-proxy routing, five-second deadline, closed response parsing, bounded relay, header filtering, and failures match on all adapters | +| Regression | non-APS Cache/ADM and notifications, pure external Prebid/native bids/EIDs/user IDs/refresh exclusions, publisher GPT/handoff/SRA/SPA, creative processing/click recovery, render trace/GPT diagnostics, and every remaining integration remain correct | +| Quality | full-package TypeScript/lint including tests/scripts/build code, ESLint boundaries, format, clippy, Rust adapter suites, Vitest, artifact integration, Playwright, deterministic bundle/performance budgets | + +### 7.2 Mandatory race matrix + +Tests must cover at least: + +- duplicate simultaneous `Prebid Request` for the same id; +- claim before/after attributable nonempty GAM, claim followed by empty GAM, and + navigation/supersession at each side of that two-condition join; +- replay after consumption and after tombstone expiry boundary; +- attempt-id navigation prefix failure, ordinal uniqueness and exhaustion without an + issued-id set; forced lifecycle-ticket/renderer-nonce collisions through the + eighth draw; 255/256/257 live nonces and 319/320/321 ticket/tombstone entries; + capacity versus expiry pruning; and proof that neither overflow path posts a usable + capability; +- valid id from wrong slot/source, altered id from the expected source, and a native + Prebid id; +- PUC registration before/after timeout, wrong source, zero/two ports, replay, + caller abort before/after registration/insertion/document acceptance, and owner + watchdog racing a late kernel response; every winner produces exactly one + encodable `OwnerSettlementV1` and one PUC Promise settlement; channel loss before + start and after insertion, settlement-post throw, and the 20-second remote cleanup + boundary prove the owner removes only its uncommitted iframe while accepted DOM + remains; +- renderer document success followed by runner failure/timeout; +- runner proxy stall and slow-drip across the five-second total deadline; redirect; + absent/duplicate/malformed/mismatched/over-limit `Content-Length`; + absent/accepted/parameterized/rejected `Content-Type`; absent/identity/listed/other + `Content-Encoding`; declared and streamed over-limit bodies; byte-preserving + success; stripped upstream headers; and empty, non-leaking `502 no-store` failure; +- GPT/Prebid readiness at either side of its deadline, `slotRequested` at either + side of the request-start deadline, and `slotRenderEnded` at either side of the + completion deadline, including late real completion of an attributable + completion-timeout cycle and proof that future events never release an + unattributable request-timeout quarantine; +- iframe `load`, `error`, removal, supersession, and navigation disposal ordering; +- accepted-artifact promotion racing terminal disposal, replacement of an accepted + direct iframe, TS-owned GPT destroy/redefine, and publisher-owned GPT navigation; +- two consecutive attempts installing identical targeting strings with newer + success, failure, and supersession; older-artifact disposal after newer promotion; + publisher different-value and same-value `setTargeting`, per-key clear, and + clear-all before each cleanup point; +- old-navigation completion after a new slot with the same DOM id exists, both + before and after the replacement's completion, for TS- and publisher-owned slots; +- two concurrent `/auction` calls with partial slot overlap, reversed responses, + one caller abort, full abort, and response timeout; +- initial boot projection versus SPA navigation-owned projection, proving boot + remains recursively frozen; stale/duplicate/malformed page-bids responses; + page-bids racing programmatic registration at the 255/256/257 combined-slot + boundary; auction/provider/upstream/currency/CPM/targeting grammar and + byte/count boundaries; canonical projection just below/at/above 8 MiB with the + exact all-winners-to-`winner_not_renderable` reduction; and all-or-nothing + projection/slot/targeting commit; +- `display()` under disabled initial load from TS and publisher callers; +- exact and hydration-renamed late `defineSlot` handoff, mismatch/ambiguity, + duplicate publisher display, explicit/global initial-load-disabled refresh, + ownership transfer, SPA disposal, and unrelated slots/options; +- DOM replacement before and after GPT request start, debounced TS-owned orphan + reconciliation at 249/250 ms and 4,999/5,000 ms, first/final pass success, + two-success capacity, expiry/rebind latch ordering, ambiguous replacement, + transfer racing replacement, exact orphan destruction/quarantine, targeting + compare-restore, cycle/reservation cleanup, throwing/false GPT destroy and failed + replacement definition in reconciliation/request-timeout/completion-timeout/ + navigation paths, proof no second physical slot is defined, navigation disposal, + and proof that publisher-owned slots are never destroyed; +- SRA completion ordering, duplicate `responseIdentifier`, missing completion, and + publisher/TS overlap; +- missing/stub/late/duplicate/older/partial external Prebid artifacts, artifact + queue-watchdog versus late module activation, ABI and release-identity boundaries, + every manifest collection/string capacity, malformed/unsorted/duplicate members, + sentinel-normalized versus final-byte integrity, exact 10.26.0, own data-property + binding, same-release duplicate reuse without module re-execution, different-valid- + release refusal without disturbing the active object, absent/accessor/inherited/ + invalid/hostile non-configurable stamp handling after the watchdog arms, publisher + callback delivery exactly once on every conflict path, bound `pbjs`/stamp replacement, + `PreparedTrustedBid` admission/non-publication, bidder aliases, client-side + adapter gaps, user-ID/EID manifest gaps, replacement by a later valid artifact, + absence of TS auction/admission/render/refresh behavior from the external artifact, + and native publisher queue/bid + survival; +- explicit/global Prebid refresh with normal, all-excluded, and mixed slots; + literal path case/trailing-slash mismatch; missing/non-string/throwing + `getAdUnitPath`; cleanup of excluded slots; original option identity; and the + initial-TS-refresh bypass; +- reservation capacity with an unexpired oldest entry and late request for that + entry; navigation slot capacity across repeated programmatic calls at totals + 255/256/257, mixed server/programmatic records, all-or-nothing overflow, and full + disposal/reuse on the next navigation; immutable winner-CPM retention across a + replaced projection before a delayed cache claim, Prebid lease promotion, ignored + cache-response price/current targeting, and separate direct-cache expansion; +- integration-module prepare throw/reject/abort and activation throw at each + checkpoint, late preparation continuation after fallback, and `afterCommit` throw; + 9,999/10,000/10,001 ms synchronous activation returns plus the pre/post-call and + pre-handoff monotonic checks; nonreturning activation documented as unpreemptable; + duplicate `afterCommit` registration and 15/16-module callback capacity; + publisher GPT activity and script/creative DOM activity before/during/after a later + module failure prove preparation is inert, activation cannot yield, rollback is + same-task, and post-commit work sees only the full kernel; queued and later + `requestAds`, callback throws, already-aborted signals, refusing late integration + registration, and proof that no second runtime, listener, port, timer, request, + script, wrapper, guard, or iframe survives a fallback commit; +- exact kernel/fallback `TsjsApi` own surfaces; semantic version and release-id + equality; boot deep-copy/freeze and malformed-field safe fallback; actual-Array + queue identity; pushes before/during/at activation and commit completion; retained ingress + references after swap; snapshot-versus-forward exactly-once behavior; nested push + ordering; frozen final-queue `length:0` under native/borrowed mutators, index and + length assignment, deletion, and property definition in strict and sloppy callers; + immediate post-load return values, `this`, non-callables, and callback throws; +- main bundle absence after server GPT projection, proving the old degraded bootstrap + renderer is deliberately gone: no GPT definition/targeting/display/refresh occurs, + every known slot settles with the committed fallback reason, the queue drains once, + and a late bundle cannot revive rendering; +- explicit `requestAds` selection with exact server-projected and programmatic slot + ids, an unknown id beside a valid sibling, GPT-path and DOM-alias collisions, and + omitted-slot snapshot membership/order while another slot registers after + invocation; +- programmatic `addAdUnits` single/array registration, boundary sizes/counts, + accessors/unknown keys/media, malformed params, duplicate/colliding ids, + all-or-nothing rollback, navigation disposal, registration after a `requestAds` + snapshot, dimension type and 0/1/4096/4097 boundaries, 63/64/65-byte bidder names, + direct rendering, fallback refusal, absence + of placeholder methods, logger default/all levels, invalid-level non-mutation, and + missing/throwing console methods; +- direct and PUC ADM initial `about:blank`, pre-assignment, intended `srcdoc`, error, + removal, replacement, duplicate load, supersession, disposal, stale-generation, + and deadline orderings, proving only the current intended navigation accepts; and +- render-trace record/update reordering, one-impression enrichment, weaker-signal + non-regression, 200-entry pruning, stale attribution/DOM-field/badge removal, + navigation pruning of `current`, 32/33 subscriber boundaries and capacity reuse, + 199/200/201 pending notification bounds, same-sequence coalescing, post-commit + asynchronous frozen subscription detail/timing, subscribe/unsubscribe races, + slow/throwing listeners, absence of `tsjs:adRendered`, + hidden/gam-only/ok truth, overlay/export failure, and cross-IIFE sequence order; +- GPT diagnostics activation before/after early buffered callbacks, exact raw-event + replay, exact `tsjs.boot.diagnostics` schema, query/session enable-disable and + fail-closed inputs, accessor/prototype/unknown/missing/version rejection and + manifest-activation mismatch, active six-listener versus inactive correctness-listener counts, + 511/512/513 fact-buffer bounds, 63/64/65 slots, 9/10/11 cycles, 127/128/129 issues, + 32/33 public subscribers, 0/1/2-update latest-snapshot coalescing, + subscribe/unsubscribe/disposal races and slow/throwing listeners, slot element + replacement, timing/frozen-export bounds, overlay disposal, inactive + zero-diagnostics-side-effect behavior, and diagnostics failure during live ads; +- creative processing across sanitize/rewrite policy combinations and every delivery + path; exact default/explicit `CreativeBootV1` validation; automatic immediate and + `DOMContentLoaded` install; disabled and enabled-with-both-guards-false + zero-side-effect behavior; idempotent rescan; + exact-wrapper disposal; absence of mutable/install globals; opaque sandbox click + recovery; absolute HTTP(S), credentials, malformed and non-network schemes; + dynamic URLs; replaced elements; and redirect/browser navigation failure; and +- every remaining integration module alone and in the maximal manifest, including missing globals, + readiness/timeouts, malformed consent/storage, matcher false positives, callback + throws, startup failure, reverse-order disposal, and cross-integration isolation; and +- every protocol string/body limit at boundary-minus-one, boundary, and + boundary-plus-one UTF-8 bytes, including multibyte, duplicate-key, malformed + encoding, exact 1/4096 renderer dimension bounds across Rust/TS/ES5/cache/PUC DOM, + and exact capability-form cases through both dispatcher and port parsers. + +### 7.3 Real-GAM pass criteria + +The checked-in test-network fixture and hermetic fakes use fictional ids and no +production demand. Real network ids, GAM creative configuration, and secrets are +injected by the protected CI/manual environment and never checked into this +repository. +Each required flow must demonstrate the expected DOM and lifecycle result, not an +analytics row: + +- APS paths: one creative request, one bridge claim where applicable, one renderer + iframe, one APS runner load, one APS render-completion callback, one accepted + result, no duplicate render. The PUC owner, static renderer, and descendant creative + each have the exact winning viewport with zero default margin, no clipping, and no + overflow. +- Empty GAM fallback: parent settles empty/failure before exactly one child render. +- Direct ADM/cache: exact owned iframe reaches one accepted result. +- Failure fixtures: wrong id, invalid descriptor, missing claim, missing owner, + missing document acknowledgement, and runner failure each reach the specified + terminal reason within the specified timeout. +- After SPA replacement, no old attempt mutates the current slot or targeting. + +The suite records browser console, network metadata, DOM, and GPT-event evidence as +CI artifacts. Network capture excludes APS runner and creative response bodies so a +test artifact cannot become an accidental vendor-code archive. It requires no +external analytics, billing, or experiment result. + +## 8. Delivery and rollout + +This work is assembled through test-only constructors while being built, then cuts +over once through the existing APS/TSJS release mechanism. No runtime flag, +old/new selector, compatibility branch, or dual protocol is introduced in any +deployable artifact. + +1. **Contract first:** land descriptor corpus, lifecycle types, adapter interfaces, + and failing tests without changing production behavior. +2. **Kernel and services:** introduce runtime/integration-module ownership, sessions, slot + registry, auction batch, and render lifecycle behind test-only construction. +3. **Server APS path:** make admission, mediation, descriptor projection, targeting, + and renderer route conform to the contract. +4. **Browser integrations:** migrate APS, GPT, Prebid, direct auction, fallback, + local diagnostics, creative processing, every remaining TSJS integration, and + bootstrap to the shared services/integration modules while their `RCJ-*` parity corpus stays + green. +5. **Delete legacy paths:** remove expandos, duplicate bridge branches, old + `requestAds`, legacy globals, duplicated bootstrap behavior, and unused flags + from the release candidate. +6. **Pre-production:** pass all hermetic suites and the protected real-GAM network + in Chromium, Firefox, and WebKit; archive its console, network, DOM, and GPT-event + evidence with the release artifact. +7. **Binary production cutover:** deploy the verified artifact through the + repository's normal release mechanism. This design adds no percentage router or + canary-selection infrastructure. Hold an exclusive production deployment window, + attest the active immutable artifact, and re-check it immediately before cutover; + any mismatch blocks and regenerates evidence. Retain that immediately prior + artifact and roll back the whole cutover on renderer errors, elevated request + failures, CSP/security errors, or non-APS regressions. +8. **Post-cutover:** monitor existing operational signals for 24 hours. The deployed + artifact already contains no temporary development selector. + +Binary rollback restores Trusted Server code but cannot restore older live APS runner +bytes. If the proxied runner becomes unavailable, incompatible, or produces suspect +completion results, the emergency containment action is to disable +`[integrations.aps]`; this stops new APS admission and makes both reserved APS routes +return their local `404 no-store` response. APS remains disabled until controlled +real-browser conformance passes again. + +This document does not prescribe new rollout telemetry. If existing operational +signals are insufficient for a deployment decision, that blocks rollout and is +resolved operationally or in a separate observability spec; it does not justify +adding a hidden analytics subsystem here. + +## 9. Decisions and rejected alternatives + +1. **Patch only the current GPT bridge:** rejected; it leaves duplicated runtime + state, SPA races, direct-auction concurrency, and bootstrap ownership unresolved. +2. **Full TSJS rewrite:** rejected; extract the kernel and migrate behavior in tested + slices so current contracts remain the oracle until cutover. +3. **Use `slotRenderEnded` as render success:** rejected; it observes GAM creative + injection, not APS runner completion. +4. **Use a bid id as the only bridge credential:** rejected; ids can collide, be + truncated, replayed, or arrive from a wrong frame. +5. **Put the lifecycle nonce in `Prebid Request`:** impossible; Universal Creative + owns that message. The nonce is minted only after a validated claim. +6. **Trust creative-document callbacks for ADM/cache:** rejected; bidder-controlled + markup must not hold the acceptance capability. The TS-authored owner observes + its iframe. +7. **Evict live reservations at capacity:** rejected; a late request could fall + through to native Prebid or claim the wrong work. Refuse new registration. +8. **Abort a shared auction fetch when one slot is superseded:** rejected; sibling + attempts may still need the response. +9. **Keep legacy globals/API aliases:** rejected; backward compatibility is not a + requirement and dual paths would preserve the architecture defect. +10. **Add analytics or experiment infrastructure to prove rollout:** rejected as a + separate project. Rendering is proven by contract, browser, and real-GAM + conformance; release uses a pre-production gate and binary artifact rollback. +11. **Check in or release a pinned APS runner:** rejected. Trusted Server owns only + the fixed-target transport proxy and renderer contract; APS owns the live runner + bytes. + +## 10. Risks and mitigations + +| Risk | Mitigation | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PUC behavior differs from mocks | vendor and checksum the exact supported PUC 1.17.2 behavior, exercise its `h.sendMessage` channel, and gate on real GAM | +| Same-realm publisher code can interfere | explicitly trust TS-authored owner code; capability checks defend unrelated frames, replays, and stale work, not arbitrary same-realm compromise | +| A module activation never returns | activation is generated first-party code with boundary tests; elapsed returning calls fail through monotonic checks, but JavaScript cannot preempt a nonreturning same-thread function | +| Strict parsing rejects a future APS field | descriptor is versioned; outer transport remains tolerant; add a reviewed version/corpus update rather than silently accepting new semantics | +| CSP blocks a legitimate APS creative | three-browser real-GAM suite; script creatives remain opt-in; CSP changes are explicit security work | +| Hard cutover breaks stale pages | accepted compatibility stance; verify pre-production and retain the prior immutable artifact for binary rollback | +| Kernel extraction changes unrelated integrations | per-integration pre/post behavior corpus, adapter fakes, current full suites, behavioral maximal-bundle test, and exact disposal assertions | +| `rc/july` moves after the design is approved | pin `905984e62`; stop before code changes, diff all inventoried TSJS/bootstrap/browser paths, and update the ledger/tests explicitly | +| Diagnostics change ad behavior or overclaim a render | one-way observation bus, bounded early-event replay, isolated subscribers, honest `gam-only`/`ok` rules, inactive zero-side-effect tests, and no correctness dependency | +| Bounded registries refuse traffic under extreme churn | explicit reservation `registry_full` and slot `registry_capacity`, lifecycle pruning, capacity stress tests; never trade correctness for eviction | +| GPT event attribution remains ambiguous | fail the TS attempt deterministically and never trigger fallback from ambiguous/publisher-owned activity | +| Late async work mutates new SPA state | generation checks, owned disposers, terminal latch, and adversarial reversed-order tests | +| Browser tests report iframe load but not APS success | require the bound APS render-completion callback and inspect network/DOM evidence | +| APS runner becomes unavailable or stops the callback | load/rejection/silence fail the attempt; real-browser conformance blocks release and APS disablement is the emergency containment path | +| APS runner reports completion incorrectly | accepted external trust risk; protected conformance checks DOM/network behavior, but cannot prove future mutable bytes; suspect behavior disables APS | +| Existing operational signals are weak | do not invent telemetry in this spec; hold deployment or write a separate observability design | + +## 11. Success criteria + +The design is complete when all of the following are true: + +1. The five supported render flows have explicit owners, identity rules, deadlines, + and terminal behavior. +2. APS descriptor production and all three validators agree on the full corpus. +3. Mediation cannot detach price from provenance or attach the wrong renderer. +4. GAM receives a valid, non-truncated identity for every accepted TS renderer bid. +5. Duplicate, replayed, wrong-source, stale-navigation, and late lifecycle messages + cannot render or settle twice. +6. Direct multi-slot auctions settle every requested slot and obey child-versus-batch + cancellation. +7. The runtime has one slot registry, one bridge listener, one adapter instance per + external library, and one explicit owner for every timer/listener/port/iframe. +8. Legacy expandos, duplicate bridge branches, old globals, old `requestAds`, and + duplicated bootstrap logic are absent from the final bundle. The `pub_id` config + alias, `/__ts/page-bids`, its JS retry, and the unversioned APS renderer path are + absent from server routes, tests, and documentation. Every bootstrap failure + checkpoint commits the terminal non-rendering shell, drains work exactly once, + and cannot construct or admit a second runtime. +9. APS renderer and runner-proxy routing, security headers, bounded relay, and failure + behavior are proven through each real adapter transport, are equivalent across all + four adapters, and never fall through to publishers. APS runner bytes are neither + stored in the repository nor required to be identical across different upstream + fetches. +10. Rust, TypeScript, ESLint, Vitest, hermetic Playwright, adapter parity, and + real-GAM conformance suites pass. +11. Non-APS Cache/ADM rendering, native Prebid handling, publisher-owned GPT, refresh, + SRA, and SPA regression suites pass. +12. No analytics, persistence, billing, experimentation, or deployment-routing + artifact is added by the implementation plan. +13. `requestAds` accepts only exact server-projected or transactionally registered + programmatic slot ids, omitted selection is an immutable invocation-time + registration-order snapshot, and ambiguous internal aliases fail closed without + affecting valid siblings. +14. Direct and PUC ADM acceptance is possible only for the exact current frame's one + intended `srcdoc` navigation; initial blank, replacement, removal, stale, late, + and duplicate events cannot accept. +15. Every `RCJ-*` ledger entry maps to an executable pre-cutover fixture, one final + owner, focused final tests, and a preserved/rebuilt/superseded disposition; the + final manifest has no unmapped TSJS source or browser contract. +16. Late GPT handoff, hydrated/ responsive DOM replacement, Prebid partial-artifact + recovery and refresh exclusions, creative security, render trace, GPT + diagnostics, and every remaining TSJS integration pass their complete parity + suites after the hard cutover. +17. PUC owner, renderer document, and descendant creative dimensions are exact and + unclipped for the winning size, while collapsed-shell correction cannot resize an + unrelated, anchor, fixed, sticky, disconnected, or already-expanded frame. +18. Programmatic ad units register transactionally into the navigation slot service, + participate in deterministic direct-auction snapshots, and render through the + same lifecycle; placeholder rendering and mutable generic runtime configuration + are absent rather than silently retained as a second path. +19. The committed `TsjsApi` kernel/fallback surfaces, semantic version, exact + release identity, queue, logger, immutable boot data, and diagnostics presence are + executable contracts; creative guards auto-install from `CreativeBootV1` with no + mutable/install global API. +20. Attempt ids require no issued-id history, and reservation/ticket/nonce registries + refuse capacity or collision exhaustion without exposing a reusable capability. +21. The external Prebid artifact remains free of TS auction/render behavior, exposes + only its exact own frozen 10.26.0 build stamp, and the TS-owned adapter admits a + fully prepared bid without partial publication. + +## 12. Open implementation decisions + +These choices may be resolved in the implementation plan without changing the +architecture: + +- exact source-file boundaries inside `kernel/`, `adapters/`, and `services/`; +- whether the canonical descriptor schema is generated from Rust metadata or a + small neutral schema file, provided all validators share the corpus and staleness + check; +- the repository's existing feature/config switch used only while the new path is + under construction; +- exact operational thresholds for the existing binary deployment mechanism. + +The `RCJ-*` ledger membership, behavioral dispositions, public diagnostics +namespace, Prebid artifact independence, and integration parity requirement are not +open implementation decisions. + +They may not be resolved by adding compatibility shims, a second runtime, external +telemetry requirements, durable persistence, experiment routing, or a weaker +lifecycle success definition. From 88f1432e33f310a202177feb656eba21ff0173de Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:57:01 -0700 Subject: [PATCH 013/194] Establish APS and TSJS implementation baseline --- .github/workflows/integration-tests.yml | 35 ++- .github/workflows/test.yml | 9 + .../tests/shared/tsjs-performance.spec.ts | 228 ++++++++++++++ crates/trusted-server-js/lib/build-all.mjs | 69 ++++- crates/trusted-server-js/lib/package.json | 1 + .../lib/scripts/check-bundle-budgets.mjs | 137 +++++++++ .../lib/scripts/check-rc-july-adoption.mjs | 166 +++++++++++ .../trusted-server-js/lib/src/core/auction.ts | 10 +- .../trusted-server-js/lib/src/core/config.ts | 3 +- .../lib/src/core/registry.ts | 2 +- .../trusted-server-js/lib/src/core/request.ts | 14 +- .../trusted-server-js/lib/src/core/types.ts | 205 +++++++------ .../lib/src/integrations/creative/click.ts | 2 +- .../lib/src/integrations/gpt/index.ts | 15 +- .../src/integrations/gpt_diagnostics/api.ts | 8 +- .../integrations/gpt_diagnostics/badges.ts | 12 +- .../integrations/gpt_diagnostics/binding.ts | 12 +- .../integrations/gpt_diagnostics/observer.ts | 14 +- .../integrations/gpt_diagnostics/overlay.ts | 22 +- .../src/integrations/gpt_diagnostics/store.ts | 36 +-- .../lib/src/integrations/osano/index.ts | 2 +- .../lib/src/integrations/prebid/index.ts | 16 +- .../lib/src/integrations/sourcepoint/index.ts | 26 +- .../lib/src/integrations/testlight/index.ts | 3 +- .../src/shared/dom_insertion_dispatcher.ts | 8 +- .../test/contract/rc-july-adoption.test.mjs | 29 ++ .../lib/test/core/auction.test.ts | 49 +-- .../lib/test/core/registry.test.ts | 2 +- .../lib/test/core/request.test.ts | 5 +- .../lib/test/core/trace.test.ts | 6 +- .../performance/aps-tsjs-prechange.json | 86 ++++++ .../lib/test/integrations/aps/render.test.ts | 44 +-- .../test/integrations/creative/click.test.ts | 4 +- .../google_tag_manager/script_guard.test.ts | 12 +- .../lib/test/integrations/gpt/ad_init.test.ts | 91 +++--- .../lib/test/integrations/gpt/index.test.ts | 16 +- .../gpt_diagnostics/binding.test.ts | 2 +- .../gpt_diagnostics/index.test.ts | 6 +- .../gpt_diagnostics/observer.test.ts | 18 +- .../gpt_diagnostics/store.test.ts | 64 ++-- .../lib/test/integrations/osano/index.test.ts | 2 +- .../test/integrations/prebid/index.test.ts | 279 +++++++++++------- .../integrations/sourcepoint/index.test.ts | 2 +- .../lib/test/shared/beacon_guard.test.ts | 9 +- crates/trusted-server-js/lib/tsconfig.json | 9 +- scripts/dispatch-workflow-run.mjs | 166 +++++++++++ scripts/integration-tests-browser.sh | 65 ++-- 47 files changed, 1525 insertions(+), 496 deletions(-) create mode 100644 crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts create mode 100644 crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs create mode 100644 crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs create mode 100644 crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs create mode 100644 crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json create mode 100644 scripts/dispatch-workflow-run.mjs diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 4973afe44..55db2a176 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -1,4 +1,6 @@ name: "Integration Tests" +run-name: >- + Integration Tests / ${{ inputs.evidence_id || format('PR {0}', github.event.pull_request.number) }} permissions: contents: read @@ -9,6 +11,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 @@ -155,7 +162,7 @@ jobs: 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 +251,29 @@ 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9ded6b860..163bf45f3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -247,5 +247,14 @@ jobs: - name: Build bundle run: npm run build + - name: Typecheck full TSJS package + run: npm run typecheck + + - name: Lint full TSJS package + run: npm run lint + + - 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/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..f88f57f55 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts @@ -0,0 +1,228 @@ +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 { + addAdUnits(unit: { + code: string; + mediaTypes: { banner: { sizes: Array<[number, number]> } }; + }): void; + renderAdUnit(code: string): void; +} + +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(() => { + const perfWindow = window as unknown as { + tsjs: PerfApi; + __tsjsPerf: { bootStartedAt: number; firstDisplayAt: number | null }; + }; + perfWindow.tsjs.addAdUnits({ + code: "perf-slot", + mediaTypes: { banner: { sizes: [[300, 250]] } }, + }); + perfWindow.tsjs.renderAdUnit("perf-slot"); + const firstDisplayAt = + perfWindow.__tsjsPerf.firstDisplayAt ?? performance.now(); + return firstDisplayAt - perfWindow.__tsjsPerf.bootStartedAt; + }); +} + +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(() => { + const perfWindow = window as unknown as { tsjs: PerfApi }; + perfWindow.tsjs.renderAdUnit("perf-slot"); + }); + retainedHeapBytes.afterRefresh = await collectHeap(heapFixture.page); + await heapFixture.page.evaluate(() => { + 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 }; + perfWindow.tsjs.renderAdUnit("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-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 2bfee01b1..17b12c11d 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -15,7 +15,9 @@ */ import fs from 'node:fs'; +import { createHash } from 'node:crypto'; import path from 'node:path'; +import { brotliCompressSync, constants as zlibConstants, gzipSync } from 'node:zlib'; import { fileURLToPath } from 'node:url'; import { build } from 'vite'; @@ -23,6 +25,37 @@ 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'); +const metricsFile = 'tsjs-build-metrics-v1.json'; + +const REFERENCE_INTEGRATIONS = ['creative', 'gpt', 'prebid']; + +function compress(bytes) { + return { + 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, + }; +} + +function measureBundleSet(files) { + const separator = Buffer.from('\n;\n', 'utf8'); + const parts = files.flatMap((file, index) => { + const bytes = fs.readFileSync(path.join(distDir, file)); + return index === files.length - 1 ? [bytes] : [bytes, separator]; + }); + const bytes = Buffer.concat(parts); + return { + files, + rawBytes: bytes.byteLength, + ...compress(bytes), + sha256: createHash('sha256').update(bytes).digest('hex'), + }; +} // Clean dist directory fs.rmSync(distDir, { recursive: true, force: true }); @@ -39,7 +72,7 @@ const integrationModules = fs.existsSync(integrationsDir) ); }) .sort() - : []; + : []; console.log('[build-all] Discovered integrations:', integrationModules); @@ -89,5 +122,39 @@ const builtFiles = fs .filter((f) => f.startsWith('tsjs-') && f.endsWith('.js')) .sort(); +const referenceFiles = ['tsjs-core.js', ...REFERENCE_INTEGRATIONS.map((name) => `tsjs-${name}.js`)]; +for (const file of referenceFiles) { + if (!builtFiles.includes(file)) { + throw new Error(`[build-all] Reference bundle file was not built: ${file}`); + } +} + +const metrics = { + schemaVersion: 1, + compression: { + concatenationSeparator: '\\n;\\n', + gzipLevel: 9, + gzipMtime: 0, + brotliMode: 'text', + brotliQuality: 11, + }, + modules: builtFiles.map((file) => { + const bytes = fs.readFileSync(path.join(distDir, file)); + return { + file, + rawBytes: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; + }), + sets: { + minimal: measureBundleSet(['tsjs-core.js']), + reference: measureBundleSet(referenceFiles), + maximal: measureBundleSet(builtFiles), + }, +}; + +fs.writeFileSync(path.join(distDir, 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] Wrote deterministic metrics: ${metricsFile}`); diff --git a/crates/trusted-server-js/lib/package.json b/crates/trusted-server-js/lib/package.json index 427fef1e1..e8e732736 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -10,6 +10,7 @@ "dev": "vite build --watch", "test": "vitest run", "test:watch": "vitest", + "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint --fix . --max-warnings=0", "format": "prettier --check \"**/*.{ts,tsx,js,json,css,md}\"", 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..fe79ea538 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +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 SET_NAMES = ['minimal', 'reference', 'maximal']; +const SIZE_NAMES = ['rawBytes', 'gzipBytes', 'brotliBytes']; +const MAX_GROWTH = 1.05; + +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 validateSets(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`); + } + 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`); + } + for (const sizeName of SIZE_NAMES) { + assertPositiveInteger(set[sizeName], `${label}.${setName}.${sizeName}`); + } + if (typeof set.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(set.sha256)) { + fail(`${label}.${setName}.sha256 must be 64 lowercase hexadecimal characters`); + } + } +} + +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'); + if (baseline.schemaVersion !== 1) fail('baseline.schemaVersion must equal 1'); + if (metrics.schemaVersion !== 1) fail('build metrics schemaVersion must equal 1'); + validateSets(baseline.bundles, 'baseline.bundles'); + validateSets(metrics.sets, 'buildMetrics.sets'); + + const failures = []; + for (const setName of SET_NAMES) { + const expected = baseline.bundles[setName]; + const actual = metrics.sets[setName]; + if (JSON.stringify(actual.files) !== JSON.stringify(expected.files)) { + failures.push( + `${setName}.files changed: expected ${JSON.stringify(expected.files)}, got ${JSON.stringify(actual.files)}` + ); + } + for (const sizeName of SIZE_NAMES) { + const limit = baselineOnly ? expected[sizeName] : Math.floor(expected[sizeName] * MAX_GROWTH); + if (actual[sizeName] > limit) { + failures.push( + `${setName}.${sizeName} is ${actual[sizeName]} bytes; limit is ${limit} from baseline ${expected[sizeName]}` + ); + } + } + if (baselineOnly && actual.sha256 !== expected.sha256) { + failures.push(`${setName}.sha256 differs from the pre-change build`); + } + } + + if (failures.length > 0) fail(`budget check failed:\n- ${failures.join('\n- ')}`); + + return { + baselineOnly, + baselinePath, + maxGrowthPercent: baselineOnly ? 0 : 5, + 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/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index 489d66f4d..f00171527 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -45,9 +45,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 +61,11 @@ 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; } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-js/lib/src/core/config.ts b/crates/trusted-server-js/lib/src/core/config.ts index c0bbe7428..7026c4420 100644 --- a/crates/trusted-server-js/lib/src/core/config.ts +++ b/crates/trusted-server-js/lib/src/core/config.ts @@ -1,5 +1,6 @@ // Global configuration storage for the tsjs runtime (logging, debug, etc.). -import { log, LogLevel } from './log'; +import { log } from './log'; +import type { LogLevel } from './log'; export interface Config { debug?: boolean; diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 9af4a0a34..06401a5e6 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -17,7 +17,7 @@ export function addAdUnits(units: AdUnit | AdUnit[]): void { // 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; + return sizes && sizes.length ? sizes[0]! : null; } // Return a snapshot array of all registered ad units. diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index f22c1dbfc..4018ac5e8 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -10,21 +10,21 @@ import { isEffectivelyVisible, recordRender, stampCreativeTrace } from './trace' export type RequestAdsCallback = () => void; export interface RequestAdsOptions { - bidsBackHandler?: RequestAdsCallback; - timeout?: number; + bidsBackHandler?: RequestAdsCallback | undefined; + timeout?: number | undefined; } type RenderCreativeInlineOptions = { slotId: string; // Accept unknown input here because bidder JSON is untrusted at runtime. creativeHtml: unknown; - creativeWidth?: number; - creativeHeight?: number; + creativeWidth?: number | undefined; + creativeHeight?: number | undefined; seat: string; creativeId: string; - auctionId?: string; - bidId?: string; - admHash?: string; + auctionId?: string | undefined; + bidId?: string | undefined; + admHash?: string | undefined; }; // Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 336bfa93a..19dc966bc 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -6,18 +6,18 @@ export interface Banner { } export interface MediaTypes { - banner?: Banner; + banner?: Banner | undefined; } export interface Bid { bidder: string; - params?: Record; + params?: Record | undefined; } export interface AdUnit { code: string; - mediaTypes?: MediaTypes; - bids?: Bid[]; + mediaTypes?: MediaTypes | undefined; + bids?: Bid[] | undefined; } /** Minimal shape of a server-side auction slot injected into `window.tsjs.adSlots`. */ @@ -26,28 +26,28 @@ export interface AuctionSlot { gam_unit_path: string; div_id: string; formats: Array<[number, number]>; - targeting?: Record; + targeting?: Record | undefined; } /** 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; + slot_id?: string | undefined; + price?: number | null | undefined; + currency?: string | undefined; + creative?: string | null | undefined; + adomain?: string[] | null | undefined; + bidder?: string | undefined; + width?: number | undefined; + height?: number | undefined; + nurl?: string | null | undefined; + burl?: string | null | undefined; + bid_id?: string | null | undefined; + ad_id?: string | null | undefined; + creative_id?: string | null | undefined; + cache_id?: string | null | undefined; + cache_host?: string | null | undefined; + cache_path?: string | null | undefined; + metadata?: Record | undefined; } export type ApsTagType = 'iframe' | 'script'; @@ -58,7 +58,7 @@ export interface ApsRendererV1 { version: 1; accountId: string; bidId: string; - creativeId?: string; + creativeId?: string | undefined; tagType: ApsTagType; creativeUrl: string; aaxResponse: string; @@ -82,27 +82,27 @@ export interface ApsPrebidRendererEntry { /** 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; + hb_pb?: string | undefined; + hb_bidder?: string | undefined; + hb_adid?: string | undefined; + hb_cache_host?: string | undefined; + hb_cache_path?: string | undefined; /** Trace-only OpenRTB bid identifier. */ - hb_bid_id?: string; + hb_bid_id?: string | undefined; /** Trace-only server-side auction identifier. */ - hb_auction_id?: string; + hb_auction_id?: string | undefined; /** Trace-only OpenRTB creative identifier. */ - hb_crid?: string; + hb_crid?: string | undefined; /** Trace hash of delivered creative markup. */ - hb_adm_hash?: string; - nurl?: string; - burl?: string; + hb_adm_hash?: string | undefined; + nurl?: string | undefined; + burl?: string | undefined; /** Typed winning-bid renderer capability. */ - renderer?: AuctionBidRenderer; + renderer?: AuctionBidRenderer | undefined; /** Winning creative width used by the inline render bridge. */ - w?: number; + w?: number | undefined; /** Winning creative height used by the inline render bridge. */ - h?: number; + h?: number | undefined; /** * Sanitized winning creative markup for local rendering through the pbRender * bridge. Present whenever the winning bid carried a creative that passed the @@ -111,9 +111,9 @@ export interface AuctionBidData { * back to the PBS Cache coordinates. This is NOT gated by * `inject_adm_for_testing`. */ - adm?: string; + adm?: string | undefined; /** Debug-only bid field mirror. Only present when `[debug] inject_adm_for_testing = true`. */ - debug_bid?: AuctionDebugBidData; + debug_bid?: AuctionDebugBidData | undefined; } /** How a creative reached the page for a [`RenderRecord`]. */ @@ -124,17 +124,17 @@ 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; + elementId?: string | undefined; + auctionId?: string | undefined; + bidder?: string | undefined; + adId?: string | undefined; + bidId?: string | undefined; + creativeId?: string | undefined; + admHash?: string | undefined; + servedFrom?: RenderServedFrom | undefined; + gamEmpty?: boolean | undefined; + injected?: boolean | undefined; + visible?: boolean | undefined; count: number; seq: number; at: number; @@ -177,46 +177,46 @@ 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; } 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; } 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; @@ -260,13 +260,20 @@ export interface TsjsApi { 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; + setConfig?: ((cfg: Record) => void) | undefined; + getConfig?: (() => Record) | undefined; + requestAds?: + | { + (opts?: { bidsBackHandler?: (() => void) | undefined; timeout?: number | undefined }): void; + ( + callback: () => void, + opts?: { + bidsBackHandler?: (() => void) | undefined; + timeout?: number | undefined; + } + ): void; + } + | undefined; log?: { setLevel(l: 'silent' | 'error' | 'warn' | 'info' | 'debug'): void; getLevel(): 'silent' | 'error' | 'warn' | 'info' | 'debug'; @@ -278,45 +285,45 @@ export interface TsjsApi { // ── Server-side auction runtime (populated by TS edge injection) ────────── /** Ad slot definitions injected at open. */ - adSlots?: AuctionSlot[]; + adSlots?: AuctionSlot[] | undefined; /** Winning bid targeting data injected before . */ - bids?: Record; + bids?: Record | undefined; /** * 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; + apsPrebidRenderers?: Record | undefined; /** Initialises GPT slots with server-side bid targeting and calls refresh(). */ - adInit?: () => void; + adInit?: (() => void) | undefined; /** Render-trace registry: latest render per slot. */ - renders?: Record; + renders?: Record | undefined; /** Append-only history of every render. */ - renderLog?: RenderRecord[]; + renderLog?: RenderRecord[] | undefined; /** Monotonic render generation for cancelling stale async work. */ - renderGeneration?: number; + renderGeneration?: number | undefined; /** Page-global render sequence counter. */ - renderSeq?: number; + renderSeq?: number | undefined; /** GPT slot objects TS defined — used to destroy stale slots on SPA navigation. */ - prevGptSlots?: unknown[]; + prevGptSlots?: unknown[] | undefined; /** Guards one-time-per-page enableSingleRequest/enableServices calls. */ - servicesEnabled?: boolean; + servicesEnabled?: boolean | undefined; /** Maps actualDivId → slotId for slotRenderEnded billing lookup. */ - divToSlotId?: Record; + divToSlotId?: Record | undefined; /** * 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; + firedBeacons?: Record | undefined; /** Slot-level GPT targeting keys TS applied on the previous route. */ - prevSlotTargetingKeys?: Record; + prevSlotTargetingKeys?: Record | undefined; /** * 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; + adInitRefreshInProgress?: boolean | undefined; /** * Whether the publisher disabled GPT initial load through * `googletag.setConfig()` or `googletag.pubads().disableInitialLoad()`. @@ -326,13 +333,13 @@ export interface TsjsApi { * from a `refresh()`; adInit() uses this to refresh its own freshly defined * slots so they are not left blank. */ - gptInitialLoadDisabled?: boolean; + gptInitialLoadDisabled?: boolean | undefined; /** Late publisher claims for TS-created GPT slots, keyed by actual div ID. */ - gptSlotHandoffs?: Record; + gptSlotHandoffs?: Record | undefined; /** True only while TS calls a GPT function that the handoff wrappers observe. */ - gptSlotHandoffInternal?: boolean; + gptSlotHandoffInternal?: boolean | undefined; /** Guards SPA pushState hook installation. */ - spaHookInstalled?: boolean; + spaHookInstalled?: boolean | undefined; /** * Monotonic count of committed SPA navigations, incremented synchronously by * the SPA auction hook the moment it accepts a route change. The deferred @@ -345,7 +352,7 @@ export interface TsjsApi { * unchanged, and an `/a → /b → /a` round trip (where the URL compares * equal again) still advances it. */ - navGeneration?: number; + navGeneration?: number | undefined; /** * Defers the initial `adInit()` until after React hydration: window `load`, * then a double `requestAnimationFrame`. Called by the server-injected @@ -358,7 +365,9 @@ export interface TsjsApi { * [`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; + scheduleInitialAdInit?: + | ((initialBids?: Record | undefined) => void) + | undefined; /** Read-only GPT lifecycle diagnostics API, present only in an activated tab. */ - gptDiagnostics?: GptDiagnosticsApi; + gptDiagnostics?: GptDiagnosticsApi | undefined; } diff --git a/crates/trusted-server-js/lib/src/integrations/creative/click.ts b/crates/trusted-server-js/lib/src/integrations/creative/click.ts index 61b55d56c..f350c4a65 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts @@ -96,7 +96,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; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 406ba3cb2..7be05e10c 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -315,7 +315,7 @@ function patchCommandQueue(tag: Partial): void { // Only applicable when cmd is an array (pre-GPT-load case). if (Array.isArray(queue)) { for (let i = 0; i < queue.length; i++) { - queue[i] = wrapCommand(queue[i]); + queue[i] = wrapCommand(queue[i]!); } log.debug('GPT shim: command queue patched', { pendingCommands: queue.length }); } else { @@ -1087,8 +1087,11 @@ export function installTsAdInit(): void { ( slotToRefresh as GoogleTagSlot & { __tsRenderGeneration?: number } ).__tsRenderGeneration = renderGeneration; - (slotToRefresh as GoogleTagSlot & { __tsRenderBid?: AuctionBidData }).__tsRenderBid = - pending; + ( + slotToRefresh as GoogleTagSlot & { + __tsRenderBid?: AuctionBidData | undefined; + } + ).__tsRenderBid = pending; } }); // One-shot bypass: this internal refresh delivers the just-applied @@ -1408,9 +1411,9 @@ function expandAuctionPriceMacro(markup: string, cpm: number): string { /** A decoded PBS Cache bid: the renderable creative plus its render metadata. */ export interface CachedBid { adm: string; - width?: number; - height?: number; - price?: number; + width?: number | undefined; + height?: number | undefined; + price?: number | undefined; } /** diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts index 4665113e6..ab869d322 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts @@ -24,10 +24,10 @@ type ApiWindow = Window & { }; interface ApiOptions { - window?: ApiWindow; - document?: Document; - now?: () => Date; - schedule?: (callback: () => void) => void; + window?: ApiWindow | undefined; + document?: Document | undefined; + now?: (() => Date) | undefined; + schedule?: ((callback: () => void) => void) | undefined; } type ApiListener = (snapshot: GptDiagnosticsExportV1) => void; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts index cc9121f05..970bf48e4 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts @@ -19,17 +19,17 @@ interface BadgeBindings { } type BadgeWindow = Window & { - MutationObserver?: typeof MutationObserver; - ResizeObserver?: typeof ResizeObserver; + MutationObserver?: typeof MutationObserver | undefined; + ResizeObserver?: typeof ResizeObserver | undefined; }; const BADGE_MAX_WIDTH_PX = 260; const BADGE_EDGE_GUTTER_PX = 4; interface BadgeOptions { - window?: BadgeWindow; - document?: Document; - scheduleFrame?: (callback: () => void) => void; + window?: BadgeWindow | undefined; + document?: Document | undefined; + scheduleFrame?: ((callback: () => void) => void) | undefined; } function defaultScheduleFrame(callback: () => void): void { @@ -115,7 +115,7 @@ export class GptDiagnosticsBadgeManager { private readonly unsubscribeBindings: () => void; private readonly slotElementIds = new Set(); private slots: GptDiagnosticsStoreSlotSnapshot[] = []; - private layer?: HTMLElement; + private layer: HTMLElement | undefined; private mutationObserver?: MutationObserver; private resizeObserver?: ResizeObserver; private scheduled = false; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts index 489e7beb1..1a2cc048f 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts @@ -8,20 +8,20 @@ interface BindingStore { } type BindingWindow = Window & { - CSS?: typeof CSS; + CSS?: typeof CSS | undefined; HTMLElement: typeof HTMLElement; - MutationObserver?: typeof MutationObserver; + MutationObserver?: typeof MutationObserver | undefined; }; interface BindingOptions { - document?: Document; - window?: BindingWindow; - scheduleFrame?: (callback: () => void) => void; + document?: Document | undefined; + window?: BindingWindow | undefined; + scheduleFrame?: ((callback: () => void) => void) | undefined; } export interface GptDiagnosticsBindingView { binding: GptDiagnosticsBinding; - element?: HTMLElement; + element?: HTMLElement | undefined; visible: boolean; } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts index a8339ffc3..12c5d64ae 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts @@ -18,10 +18,10 @@ interface GptEvent { } interface GptRenderEvent extends GptEvent { - isEmpty?: boolean; + isEmpty?: boolean | undefined; size?: unknown; - isBackfill?: boolean; - slotContentChanged?: boolean; + isBackfill?: boolean | undefined; + slotContentChanged?: boolean | undefined; } interface GptVisibilityEvent extends GptEvent { @@ -48,11 +48,11 @@ interface GptCommandQueue { interface GoogletagLike { cmd: GptCommandQueue; - pubads?: () => GptPubAdsService; + pubads?: (() => GptPubAdsService) | undefined; } export interface GptObserverWindow { - googletag?: GoogletagLike; + googletag?: GoogletagLike | undefined; } interface ObserverLogger { @@ -60,8 +60,8 @@ interface ObserverLogger { } interface ObserverOptions { - window?: GptObserverWindow; - logger?: ObserverLogger; + window?: GptObserverWindow | undefined; + logger?: ObserverLogger | undefined; } function normalizeSize(value: unknown): Size | undefined { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts index cacdbff9b..22614d74c 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts @@ -19,16 +19,16 @@ interface OverlayBindings { } type OverlayWindow = Window & { - MutationObserver?: typeof MutationObserver; + MutationObserver?: typeof MutationObserver | undefined; }; interface OverlayOptions { - window?: OverlayWindow; - document?: Document; - scheduleFrame?: (callback: () => void) => void; - onExport?: () => void; - onShadowRoot?: (root: ShadowRoot) => void; - onBadgeLayerChange?: (layer: HTMLElement | undefined) => void; + window?: OverlayWindow | undefined; + document?: Document | undefined; + scheduleFrame?: ((callback: () => void) => void) | undefined; + onExport?: (() => void) | undefined; + onShadowRoot?: ((root: ShadowRoot) => void) | undefined; + onBadgeLayerChange?: ((layer: HTMLElement | undefined) => void) | undefined; } const PANEL_STYLES = ` @@ -191,12 +191,12 @@ export class GptDiagnosticsOverlay { private readonly document: Document; private readonly scheduleFrame: (callback: () => void) => void; private readonly onExport: () => void; - private readonly onShadowRoot?: (root: ShadowRoot) => void; - private readonly onBadgeLayerChange?: (layer: HTMLElement | undefined) => void; + private readonly onShadowRoot: ((root: ShadowRoot) => void) | undefined; + private readonly onBadgeLayerChange: ((layer: HTMLElement | undefined) => void) | undefined; private readonly unsubscribeStore: () => void; private readonly unsubscribeBindings: () => void; - private host?: HTMLElement; - private panel?: HTMLElement; + private host: HTMLElement | undefined; + private panel: HTMLElement | undefined; private lifecycleObserver?: MutationObserver; private visualReady = false; private mountWaitStarted = false; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts index 669f28ddb..c5efe25cd 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts @@ -22,29 +22,29 @@ const CALLBACK_KINDS: GptDiagnosticsCallbackKind[] = [ ]; export interface GptDiagnosticsSlotLike { - getSlotElementId?(): string; - getAdUnitPath?(): string; + getSlotElementId?: (() => string) | undefined; + getAdUnitPath?: (() => string) | undefined; } export interface GptRenderFacts { - isEmpty?: boolean; - size?: Size; - isBackfill?: boolean; - slotContentChanged?: boolean; + isEmpty?: boolean | undefined; + size?: Size | undefined; + isBackfill?: boolean | undefined; + slotContentChanged?: boolean | undefined; } export interface GptDiagnosticsStoreSlotSnapshot { runtimeSlotNumber: number; - slotElementId?: string; - adUnitPath?: string; - currentVisibilityPercentage?: number; - maximumVisibilityPercentage?: number; + slotElementId?: string | undefined; + adUnitPath?: string | undefined; + currentVisibilityPercentage?: number | undefined; + maximumVisibilityPercentage?: number | undefined; requests: GptDiagnosticsRequestCycle[]; } export interface GptDiagnosticsBindingInput { runtimeSlotNumber: number; - slotElementId?: string; + slotElementId?: string | undefined; } export interface GptDiagnosticsStoreSnapshot { @@ -63,16 +63,16 @@ type MutableRequestCycle = GptDiagnosticsRequestCycle; interface MutableSlotRecord { runtimeSlotNumber: number; - slotElementId?: string; - adUnitPath?: string; - currentVisibilityPercentage?: number; - maximumVisibilityPercentage?: number; + slotElementId?: string | undefined; + adUnitPath?: string | undefined; + currentVisibilityPercentage?: number | undefined; + maximumVisibilityPercentage?: number | undefined; requests: MutableRequestCycle[]; } interface StoreOptions { - now?: () => number; - schedule?: (callback: () => void) => void; + now?: (() => number) | undefined; + schedule?: ((callback: () => void) => void) | undefined; } type StoreListener = () => void; @@ -451,7 +451,7 @@ export class GptDiagnosticsStore { return; } - attach(record, candidates[0]); + attach(record, candidates[0]!); this.incrementDisposition(kind, 'matched'); this.notify(); } diff --git a/crates/trusted-server-js/lib/src/integrations/osano/index.ts b/crates/trusted-server-js/lib/src/integrations/osano/index.ts index 8bd3daae1..ab12df202 100644 --- a/crates/trusted-server-js/lib/src/integrations/osano/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/osano/index.ts @@ -273,7 +273,7 @@ function readGppSignal(win: OsanoWindow): Promise { return; } - const applicableSections = data.applicableSections; + const applicableSections = data.applicableSections as number[] | undefined; if (typeof data.gppString === 'string' && data.gppString.length > 0) { const writes = [{ name: GPP_COOKIE_NAME, value: data.gppString }]; const clears: string[] = []; diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 207bbeae1..df0cccc63 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -52,9 +52,9 @@ const pbjs: PbjsGlobal = ( * user ID modules were compiled into it. */ interface ExternalPrebidBundleManifest { - adapters?: string[]; - bidderCodes?: string[]; - userIdModules?: string[]; + adapters?: string[] | undefined; + bidderCodes?: string[] | undefined; + userIdModules?: string[] | undefined; } function sanitizeManifestList(value: unknown): string[] | undefined { @@ -132,11 +132,11 @@ const PENDING_PUBLISHER_DELIVERY_TTL_MS = 5000; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { /** Auction endpoint path. Defaults to '/auction'. */ - endpoint?: string; + endpoint?: string | undefined; /** Server-side bid timeout in milliseconds. Defaults to 1000. */ - timeout?: number; + timeout?: number | undefined; /** Enable Prebid.js debug logging. Defaults to false. */ - debug?: boolean; + debug?: boolean | undefined; } /** @@ -1082,7 +1082,9 @@ export function installPrebidNpm(config?: Partial): typeof pbjs if (hasUserIdApi && !auctionEids) { clearPrebidEidsCookie(); } - const payload = buildAdRequest(validBidRequests, { eids: auctionEids }); + const payload = buildAdRequest(validBidRequests, { + eids: auctionEids, + } as Parameters[1]); return { method: 'POST', url: auctionEndpoint, diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts index f323468bb..9e181c94d 100644 --- a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts @@ -3,9 +3,11 @@ import { log } from '../../core/log'; import { installSourcepointGuard } from './script_guard'; type SourcepointWindow = Window & { - __tsjs_sourcepoint?: { - rewriteSdk?: boolean; - }; + __tsjs_sourcepoint?: + | { + rewriteSdk?: boolean | undefined; + } + | undefined; }; function shouldInstallSourcepointGuard(): boolean { @@ -30,33 +32,33 @@ const GPP_SOURCE_SOURCEPOINT = 'sp'; const INITIAL_RETRY_DELAY_MS = 500; interface SourcepointGppData { - gppString?: string; - applicableSections?: number[]; + gppString?: string | undefined; + applicableSections?: number[] | undefined; } interface SourcepointConsentStringEntry { - sectionId?: number; + sectionId?: number | undefined; } interface SourcepointSectionPayload { - consentString?: string; - applicableSections?: number[]; - consentStrings?: SourcepointConsentStringEntry[]; + consentString?: string | undefined; + applicableSections?: number[] | undefined; + consentStrings?: SourcepointConsentStringEntry[] | undefined; } interface SourcepointConsentPayload { - gppData?: SourcepointGppData; + gppData?: SourcepointGppData | undefined; [key: string]: unknown; } interface MirroredSourcepointConsent { gppString: string; - applicableSections?: number[]; + applicableSections?: number[] | undefined; } let initialized = false; let initialRetryDone = false; -let retryTimer: ReturnType | undefined; +let retryTimer: number | undefined; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; diff --git a/crates/trusted-server-js/lib/src/integrations/testlight/index.ts b/crates/trusted-server-js/lib/src/integrations/testlight/index.ts index 3db00ce05..7f2598b17 100644 --- a/crates/trusted-server-js/lib/src/integrations/testlight/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/testlight/index.ts @@ -1,7 +1,8 @@ import type { TsjsApi } from '../../core/types'; import { installQueue } from '../../core/queue'; import { log } from '../../core/log'; -import { resolvePrebidWindow, PrebidWindow } from '../../shared/globals'; +import { resolvePrebidWindow } from '../../shared/globals'; +import type { PrebidWindow } from '../../shared/globals'; type TestlightCallback = () => void; diff --git a/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts b/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts index 5fb9283be..1046d951d 100644 --- a/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts +++ b/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts @@ -42,11 +42,11 @@ interface RegisteredDomInsertionHandler extends DomInsertionHandler { } interface DomInsertionDispatcherState { - appendChildWrapper?: AppendChildMethod; - baselineAppendChild?: AppendChildMethod; - baselineInsertBefore?: InsertBeforeMethod; + appendChildWrapper?: AppendChildMethod | undefined; + baselineAppendChild?: AppendChildMethod | undefined; + baselineInsertBefore?: InsertBeforeMethod | undefined; handlers: Map; - insertBeforeWrapper?: InsertBeforeMethod; + insertBeforeWrapper?: InsertBeforeMethod | undefined; nextSequence: number; orderedHandlers: RegisteredDomInsertionHandler[]; version: number; diff --git a/crates/trusted-server-js/lib/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..a47080105 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -4,7 +4,7 @@ import { buildAdRequest, parseAuctionResponse, sendAuction } from '../../src/cor import envelope from '../fixtures/aps-renderer-v1.json'; 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, @@ -42,14 +42,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 +84,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 +142,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 +153,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 +248,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 +268,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 +310,11 @@ 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([]); }); }); @@ -365,7 +368,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/registry.test.ts b/crates/trusted-server-js/lib/test/core/registry.test.ts index f06527e42..51b0e3a84 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -24,6 +24,6 @@ describe('registry', () => { const all = getAllUnits(); expect(all.length).toBe(1); - expect(firstSize(all[0])!.join('x')).toBe('320x50'); + expect(firstSize(all[0]!)!.join('x')).toBe('320x50'); }); }); 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..2fc652053 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import envelope from '../fixtures/aps-renderer-v1.json'; +import type { addAdUnits } from '../../src/core/registry'; /** Test view of the global scope with a mockable `fetch`. */ const testGlobal = globalThis as unknown as { fetch: ReturnType }; @@ -77,7 +78,7 @@ describe('request.requestAds', () => { }); it('dispatches a valid APS descriptor to the opaque static renderer route', async () => { - const apsBid = envelope.seatbid[0].bid[0]; + const apsBid = envelope.seatbid[0]!.bid[0]!; const renderer = { type: 'aps', version: 1, @@ -134,7 +135,7 @@ describe('request.requestAds', () => { expect(document.querySelector('#slot1 span')).not.toBeNull(); expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ renderer }), '*'); - const message = postMessage.mock.calls[0][0] as { nonce: string }; + 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 }, diff --git a/crates/trusted-server-js/lib/test/core/trace.test.ts b/crates/trusted-server-js/lib/test/core/trace.test.ts index 2181340f2..3317f67e1 100644 --- a/crates/trusted-server-js/lib/test/core/trace.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace.test.ts @@ -69,7 +69,7 @@ describe('trace/recordRender', () => { const record = recordRender({ slotId: 'slot-ev', path: 'auction', rendered: true }); expect(listener).toHaveBeenCalledTimes(1); - const event = listener.mock.calls[0][0] as CustomEvent; + const event = listener.mock.calls[0]![0] as CustomEvent; expect(event.detail).toEqual(record); window.removeEventListener(RENDER_EVENT_NAME, listener); @@ -297,8 +297,8 @@ describe('trace/floating panel', () => { 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'); + 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", () => { 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..77e672aa7 --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json @@ -0,0 +1,86 @@ +{ + "schemaVersion": 1, + "mode": "baseline", + "source": { + "ref": "spec/aps-tsjs-resilience-design", + "sha": "1b9753efa01a7985ae8ea804b1997a3cf3b3a7b4" + }, + "environment": { + "node": "v24.12.0", + "npm": "11.6.2", + "typescript": "5.9.3", + "chromium": "145.0.7632.6", + "ciMachineClass": "local:darwin-arm64", + "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"], + "rawBytes": 107265, + "gzipBytes": 33428, + "brotliBytes": 25236, + "sha256": "8b9a440310ad358c292864dfa2e088c895c59199c2518fe9a3044236e459d19d" + }, + "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": [ + 11.300000011920929, 11.400000005960464, 11, 10.599999994039536, 11.599999994039536, + 13.199999988079071, 11.799999982118607, 11.100000023841858, 11, 11.799999982118607, 11.5, + 10.5, 10.800000011920929, 11.899999976158142, 11.199999988079071, 10.600000023841858, + 10.799999982118607, 11.099999994039536, 10.800000011920929, 10.699999988079071, 10.5, + 11.200000017881393, 10.599999994039536, 10.5, 10.800000011920929, 10.5, 10.300000011920929, + 10.199999988079071, 10.800000011920929, 10.700000017881393, 10.299999982118607, + 10.800000011920929, 10.800000011920929, 11.099999994039536, 15.699999988079071, + 10.399999976158142, 10.599999994039536, 10.5, 10.800000011920929, 10.599999994039536, + 10.699999988079071, 10.599999994039536, 11.599999994039536, 11.699999988079071, + 11.599999994039536, 10.5, 10.699999988079071, 10.400000005960464, 11.099999994039536, + 11.700000017881393 + ], + "p90": 11.700000017881393 + }, + "retainedHeapBytes": { + "afterBoot": 1210116, + "afterFirstRender": 1213316, + "afterRefresh": 1213316, + "afterSpaNavigation": 1220364 + } + }, + "evidence": { + "evidenceId": null, + "workflowRunId": null + } +} 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 04ad4fece..2ee02cb56 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 @@ -34,7 +34,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, @@ -93,19 +93,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 +114,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 +153,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 +167,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 +192,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( @@ -298,7 +298,7 @@ describe('direct APS rendering', () => { '*' ); - const message = postMessage.mock.calls[0][0] as { nonce: string }; + const message = postMessage.mock.calls[0]![0] as { nonce: string }; window.dispatchEvent( new MessageEvent('message', { data: { @@ -324,10 +324,10 @@ describe('direct APS rendering', () => { expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); const slot = document.getElementById('fictional-slot')!; - const rendererFrame = slot.querySelector('iframe')!; + 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 sent = postMessage.mock.calls[0]![0] as { nonce: string }; const foreignFrame = document.createElement('iframe'); document.body.appendChild(foreignFrame); @@ -363,7 +363,7 @@ describe('direct APS rendering', () => { expect(document.querySelector('#fictional-slot iframe')).toBeNull(); expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const iframe = document.querySelector('#fictional-slot iframe')!; + 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(); @@ -373,7 +373,7 @@ describe('direct APS rendering', () => { vi.useFakeTimers(); try { expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const iframe = document.querySelector('#fictional-slot iframe')!; + const iframe = document.querySelector('#fictional-slot iframe')!; iframe.dispatchEvent(new Event('load')); vi.advanceTimersByTime(10_000); @@ -391,20 +391,20 @@ describe('direct APS rendering', () => { try { const baselineTimers = vi.getTimerCount(); expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const firstFrame = document.querySelector('#fictional-slot iframe')!; + 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 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')!; + 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 }; + const sent = postMessage.mock.calls[0]![0] as { nonce: string }; window.dispatchEvent( new MessageEvent('message', { @@ -467,14 +467,14 @@ describe('Universal Creative APS source', () => { undefined, window ); - const iframe = document.body.querySelector('iframe')!; + const iframe = document.body.querySelector('iframe')!; expect(iframe.src).toMatch(/\/integrations\/aps\/renderer#tsaps=[A-Za-z0-9_-]{22}$/); expect(iframe.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); expect(iframe.getAttribute('sandbox')).not.toContain('allow-same-origin'); const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); iframe.dispatchEvent(new Event('load')); - const sent = postMessage.mock.calls[0][0] as { nonce: string; renderer: ApsRendererV1 }; + const sent = postMessage.mock.calls[0]![0] as { nonce: string; renderer: ApsRendererV1 }; expect(sent.renderer).toEqual(renderer); let settled = false; 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..fae4eb407 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 @@ -64,7 +64,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({ @@ -203,7 +203,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)); 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 index 9469d94aa..1b3d63ff2 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -4,9 +4,10 @@ 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 { GptSlotHandoff, TsjsApi } from '../../../src/core/types'; function apsRenderer() { - const bid = envelope.seatbid[0].bid[0]; + const bid = envelope.seatbid[0]!.bid[0]!; return { type: 'aps' as const, version: 1 as const, @@ -74,10 +75,14 @@ interface PrebidResponseMessage { // `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 TestGptSlotHandoff = Omit & { formats: number[][] }; +type TestTsjsApi = Omit, 'gptSlotHandoffs'> & { + gptSlotHandoffs?: Record | undefined; +}; type TestWindow = Omit & { googletag?: unknown; apstag?: { setDisplayBids?: () => void }; - tsjs?: Partial; + tsjs?: TestTsjsApi; }; function appendResponsiveSlotElement( @@ -438,8 +443,8 @@ describe('installTsAdInit', () => { 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_'] + expect((window as TestWindow).tsjs!.gptSlotHandoffs![hydratedId]).toBe( + (window as TestWindow).tsjs!.gptSlotHandoffs!['ad-header-0-_R_0_'] ); } ); @@ -605,9 +610,9 @@ describe('installTsAdInit', () => { 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 = { + 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', }; @@ -936,7 +941,7 @@ describe('installTsAdInit', () => { (pubads.refresh as () => void)(); expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot]); - expect((window as TestWindow).tsjs!.gptSlotHandoffs['div-claimed']).toEqual( + expect((window as TestWindow).tsjs!.gptSlotHandoffs!['div-claimed']).toEqual( expect.objectContaining({ suppressPublisherRefresh: false }) ); }); @@ -2085,7 +2090,7 @@ describe('installTsAdInit', () => { atf_sidebar_ad: { hb_pb: '1.50', hb_bidder: 'aps', - hb_adid: envelope.seatbid[0].bid[0].id, + hb_adid: envelope.seatbid[0]!.bid[0]!.id, renderer: apsRenderer(), }, }, @@ -2480,7 +2485,7 @@ describe('installTsRenderBridge', () => { it('serves one exact APS dynamic-renderer response without cache fetches or beacons', async () => { const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { + (window as TestWindow).tsjs!.bids!.homepage_header = { hb_adid: renderer.bidId, hb_bidder: 'aps', hb_pb: '1.23', @@ -2514,7 +2519,7 @@ describe('installTsRenderBridge', () => { // 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; + const response = JSON.parse(portMessages[0]!) as Record; expect(Object.keys(response).sort()).toEqual( [ 'adId', @@ -2557,7 +2562,7 @@ describe('installTsRenderBridge', () => { const rendererPost = vi.spyOn(outerFrame.contentWindow!, 'postMessage'); outerFrame.dispatchEvent(new Event('load')); - const sent = rendererPost.mock.calls[0][0] as { nonce: string }; + const sent = rendererPost.mock.calls[0]![0] as { nonce: string }; window.dispatchEvent( new MessageEvent('message', { data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, @@ -2577,7 +2582,7 @@ describe('installTsRenderBridge', () => { const prebidAdId = 'prebid-generated-ad-id'; const markWinner = vi.fn(); const markRendered = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { + (window as TestWindow).tsjs!.apsPrebidRenderers = { [prebidAdId]: { adUnitCode: 'div-header', renderer, @@ -2615,7 +2620,7 @@ describe('installTsRenderBridge', () => { expect(portMessages).toHaveLength(1); expect(markWinner).toHaveBeenCalledTimes(1); expect(markRendered).toHaveBeenCalledTimes(1); - expect(JSON.parse(portMessages[0])).toEqual( + expect(JSON.parse(portMessages[0]!)).toEqual( expect.objectContaining({ message: 'Prebid Response', adId: prebidAdId, @@ -2625,7 +2630,7 @@ describe('installTsRenderBridge', () => { }) ); expect(renderer.bidId).not.toBe(prebidAdId); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); + expect((window as TestWindow).tsjs!.apsPrebidRenderers![prebidAdId]).toBeUndefined(); expect(fetchStub).not.toHaveBeenCalled(); foreignIframe.remove(); }); @@ -2637,7 +2642,7 @@ describe('installTsRenderBridge', () => { throw new Error('fictional markWinner failure'); }); const markRendered = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { + (window as TestWindow).tsjs!.apsPrebidRenderers = { [prebidAdId]: { adUnitCode: 'div-header', renderer, @@ -2664,7 +2669,7 @@ describe('installTsRenderBridge', () => { ).not.toThrow(); expect(portMessages).toHaveLength(1); - expect(JSON.parse(portMessages[0])).toEqual( + expect(JSON.parse(portMessages[0]!)).toEqual( expect.objectContaining({ message: 'Prebid Response', adId: prebidAdId, @@ -2682,7 +2687,7 @@ describe('installTsRenderBridge', () => { const markRendered = vi.fn(() => { throw new Error('fictional markRendered failure'); }); - (window as TestWindow).tsjs.apsPrebidRenderers = { + (window as TestWindow).tsjs!.apsPrebidRenderers = { [prebidAdId]: { adUnitCode: 'div-header', renderer, @@ -2709,7 +2714,7 @@ describe('installTsRenderBridge', () => { ).not.toThrow(); expect(portMessages).toHaveLength(1); - expect(JSON.parse(portMessages[0])).toEqual( + expect(JSON.parse(portMessages[0]!)).toEqual( expect.objectContaining({ message: 'Prebid Response', adId: prebidAdId, @@ -2730,7 +2735,7 @@ describe('installTsRenderBridge', () => { const firstMarkRendered = vi.fn(); const secondMarkWinner = vi.fn(); const secondMarkRendered = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { + (window as TestWindow).tsjs!.apsPrebidRenderers = { [prebidAdId]: { adUnitCode: 'div-header', renderer, @@ -2758,7 +2763,7 @@ describe('installTsRenderBridge', () => { sendRequest(); vi.advanceTimersByTime(60_001); - (window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId] = { + (window as TestWindow).tsjs!.apsPrebidRenderers![prebidAdId] = { adUnitCode: 'div-header', renderer, registeredAt: Date.now(), @@ -2798,7 +2803,7 @@ describe('installTsRenderBridge', () => { }, ]) ); - (window as TestWindow).tsjs.apsPrebidRenderers = entries; + (window as TestWindow).tsjs!.apsPrebidRenderers = entries; const bridgeListener = await captureBridgeListener(); const source = createTrustedSlotIframe(); @@ -2822,17 +2827,17 @@ describe('installTsRenderBridge', () => { sendRequest('capacity-ad-0'); expect(portMessages).toHaveLength(capacity); - expect(callbacks[capacity].markWinner).not.toHaveBeenCalled(); - expect(callbacks[capacity].markRendered).not.toHaveBeenCalled(); + 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(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 = { + (window as TestWindow).tsjs!.apsPrebidRenderers = { [prebidAdId]: { adUnitCode: 'div-header', renderer, @@ -2863,13 +2868,13 @@ describe('installTsRenderBridge', () => { expect(stopSpy).toHaveBeenCalledTimes(1); expect(portMessages).toEqual([]); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeDefined(); + 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 = { + (window as TestWindow).tsjs!.apsPrebidRenderers = { [prebidAdId]: { adUnitCode: 'div-header', renderer: apsRenderer(), @@ -2895,12 +2900,12 @@ describe('installTsRenderBridge', () => { expect(stopSpy).not.toHaveBeenCalled(); expect(portMessages).toEqual([]); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); + 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 = { + (window as TestWindow).tsjs!.bids!.homepage_header = { hb_adid: renderer.bidId, hb_bidder: 'aps', renderer, @@ -2926,12 +2931,12 @@ describe('installTsRenderBridge', () => { 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 = { + (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-'; + (window as TestWindow).tsjs!.adSlots![0]!.div_id = 'div-header-'; const bridgeListener = await captureBridgeListener(); const source = createTrustedSlotIframe('div-header-dynamic'); @@ -2951,12 +2956,12 @@ describe('installTsRenderBridge', () => { it('does not let an overlapping slot prefix claim another slot iframe', async () => { const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { + (window as TestWindow).tsjs!.bids!.homepage_header = { hb_adid: renderer.bidId, hb_bidder: 'aps', renderer, }; - (window as TestWindow).tsjs.adSlots.push({ + (window as TestWindow).tsjs!.adSlots!.push({ id: 'homepage_header_mobile', formats: [[320, 50]], gam_unit_path: '/a/b/mobile', @@ -2984,12 +2989,12 @@ describe('installTsRenderBridge', () => { it('ignores an APS ad ID requested by another configured slot', async () => { const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { + (window as TestWindow).tsjs!.bids!.homepage_header = { hb_adid: renderer.bidId, hb_bidder: 'aps', renderer, }; - (window as TestWindow).tsjs.adSlots.push({ + (window as TestWindow).tsjs!.adSlots!.push({ id: 'homepage_footer', formats: [[300, 250]], gam_unit_path: '/a/b/footer', @@ -3077,7 +3082,7 @@ describe('installTsRenderBridge', () => { expect(stopSpy).toHaveBeenCalled(); expect(portMessages).toHaveLength(1); - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + 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); @@ -3160,7 +3165,7 @@ describe('installTsRenderBridge', () => { expect(portMessages).toHaveLength(1); - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; expect(parsed.ad).toBe(rawAd); expect(beaconSpy).toHaveBeenCalledTimes(2); beaconSpy.mockRestore(); @@ -3192,7 +3197,7 @@ describe('installTsRenderBridge', () => { expect(portMessages).toHaveLength(1); - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; expect(parsed.width).toBe(300); expect(parsed.height).toBe(250); beaconSpy.mockRestore(); @@ -3228,7 +3233,7 @@ describe('installTsRenderBridge', () => { expect(portMessages).toHaveLength(1); - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; expect(parsed.ad).toContain('p=2.5'); expect(parsed.ad).not.toContain('${AUCTION_PRICE}'); beaconSpy.mockRestore(); @@ -3431,7 +3436,7 @@ describe('installTsRenderBridge', () => { expect(stopSpy).toHaveBeenCalled(); expect(portMessages).toHaveLength(1); - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + 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); @@ -3492,7 +3497,7 @@ describe('installTsRenderBridge', () => { expect(portMessages).toHaveLength(1); - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; expect(parsed.width).toBe(300); expect(parsed.height).toBe(250); } finally { @@ -3567,7 +3572,7 @@ describe('installTsRenderBridge', () => { expect(portMessages).toHaveLength(1); - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + 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); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts index e8251be89..82ddc5b88 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts @@ -44,7 +44,7 @@ describe('GPT shim – patchCommandQueue', () => { it('preserves googletag.cmd array identity', () => { const originalCmd: Array<() => void> = []; - win.googletag = { cmd: originalCmd } as GptWindow['googletag']; + win.googletag = { cmd: originalCmd }; installGptShim(); @@ -65,7 +65,7 @@ describe('GPT shim – patchCommandQueue', () => { }; cmd.push = gptCustomPush; - win.googletag = { cmd, _loaded_: true } as GptWindow['googletag']; + win.googletag = { cmd, _loaded_: true }; installGptShim(); @@ -79,7 +79,7 @@ describe('GPT shim – patchCommandQueue', () => { }); it('wraps callbacks pushed after patching with error handling', () => { - win.googletag = { cmd: [] } as GptWindow['googletag']; + win.googletag = { cmd: [] }; installGptShim(); @@ -92,7 +92,7 @@ describe('GPT shim – patchCommandQueue', () => { // The wrapped callback should be in the queue — execute it. const wrappedFn = win.googletag!.cmd[win.googletag!.cmd.length - 1]; - expect(() => wrappedFn()).not.toThrow(); + expect(() => wrappedFn!()).not.toThrow(); errorSpy.mockRestore(); }); @@ -101,7 +101,7 @@ describe('GPT shim – patchCommandQueue', () => { const callOrder: string[] = []; const pending = [() => callOrder.push('first'), () => callOrder.push('second')]; - win.googletag = { cmd: pending } as GptWindow['googletag']; + win.googletag = { cmd: pending }; installGptShim(); @@ -123,7 +123,7 @@ describe('GPT shim – patchCommandQueue', () => { () => callOrder.push('after-error'), ]; - win.googletag = { cmd: pending } as GptWindow['googletag']; + win.googletag = { cmd: pending }; installGptShim(); @@ -137,7 +137,7 @@ describe('GPT shim – patchCommandQueue', () => { it('is idempotent — calling installGptShim twice does not double-wrap', () => { const calls: number[] = []; - win.googletag = { cmd: [] } as GptWindow['googletag']; + win.googletag = { cmd: [] }; installGptShim(); const pushAfterFirst = win.googletag!.cmd.push; @@ -151,7 +151,7 @@ describe('GPT shim – patchCommandQueue', () => { // Push a callback and verify it only executes once (not double-wrapped). win.googletag!.cmd.push(() => calls.push(1)); const fn = win.googletag!.cmd[win.googletag!.cmd.length - 1]; - fn(); + fn!(); expect(calls).toEqual([1]); }); 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..67b57b77d 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 @@ -118,7 +118,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', () => { 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 f53683ee2..69a4508f3 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 @@ -148,8 +148,8 @@ describe('GPT diagnostics integration composition', () => { await settle(); 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).toHaveLength(1); + expect(api.snapshot().slots[0]!.requests[0]!.isEmpty).toBe(false); api.show(); expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).not.toBeNull(); @@ -202,7 +202,7 @@ describe('GPT diagnostics integration composition', () => { binding: { status: 'bound' }, currentVisibilityPercentage: 75, }); - expect(snapshot.slots[0].requests.map((cycle) => cycle.requestNumber)).toEqual([1, 2, 3, 4]); + expect(snapshot.slots[0]!.requests.map((cycle) => cycle.requestNumber)).toEqual([1, 2, 3, 4]); expect(snapshot.callbackIssues).toContainEqual( expect.objectContaining({ kind: 'slotResponseReceived', 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 ab39491e6..b1b8fc95d 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 @@ -80,7 +80,7 @@ describe('GptDiagnosticsObserver', () => { expect(gpt.googletag.cmd).toHaveLength(1); expect(gpt.pubads.addEventListener).not.toHaveBeenCalled(); - gpt.googletag.cmd[0](); + gpt.googletag.cmd[0]!(); expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); expect(gpt.pubads.addEventListener.mock.calls.map(([name]) => name)).toEqual(EVENT_NAMES); @@ -96,9 +96,9 @@ describe('GptDiagnosticsObserver', () => { observer.install(); expect(gpt.googletag.cmd).toHaveLength(1); - gpt.googletag.cmd[0](); + gpt.googletag.cmd[0]!(); observer.install(); - gpt.googletag.cmd[0](); + gpt.googletag.cmd[0]!(); expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); expect(store.markGptObserved).toHaveBeenCalledTimes(1); @@ -119,7 +119,7 @@ describe('GptDiagnosticsObserver', () => { expect(delayedWindow.googletag?.cmd).toHaveLength(1); const gpt = controlledGpt(); delayedWindow.googletag!.pubads = gpt.googletag.pubads; - delayedWindow.googletag!.cmd[0](); + delayedWindow.googletag!.cmd[0]!(); expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); }); @@ -154,7 +154,7 @@ describe('GptDiagnosticsObserver', () => { const slot = fakeSlot(); const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); observer.install(); - gpt.googletag.cmd[0](); + gpt.googletag.cmd[0]!(); gpt.emit('slotRequested', { slot }); gpt.emit('slotResponseReceived', { slot }); @@ -189,7 +189,7 @@ describe('GptDiagnosticsObserver', () => { const slot = fakeSlot(); const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); observer.install(); - gpt.googletag.cmd[0](); + gpt.googletag.cmd[0]!(); gpt.emit('slotRenderEnded', { slot, isEmpty: false, size: 'fluid' }); @@ -208,7 +208,7 @@ describe('GptDiagnosticsObserver', () => { const gpt = controlledGpt(); const observer = new GptDiagnosticsObserver(store, { window: gpt.window, logger }); observer.install(); - gpt.googletag.cmd[0](); + gpt.googletag.cmd[0]!(); const event = { get slot(): GptDiagnosticsSlotLike { throw new Error('slot accessor failed'); @@ -248,7 +248,7 @@ describe('GptDiagnosticsObserver', () => { }); listenerObserver.install(); - expect(() => gpt.googletag.cmd[0]()).not.toThrow(); + expect(() => gpt.googletag.cmd[0]!()).not.toThrow(); expect(logger.warn).toHaveBeenCalledTimes(2); }); @@ -267,7 +267,7 @@ describe('GptDiagnosticsObserver', () => { }; observer.install(); - gpt.googletag.cmd[0](); + gpt.googletag.cmd[0]!(); expect(gpt.googletag.display).toBe(references.display); expect(gpt.googletag.defineSlot).toBe(references.defineSlot); 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 8b2dbed72..7c176365d 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 @@ -47,8 +47,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({ @@ -111,8 +111,8 @@ describe('GptDiagnosticsStore', () => { viewableAtMs: 8, durations: { renderToLoadMs: 2, renderToViewableMs: 5 }, }); - expect(emptyCycle.loadAtMs).toBeUndefined(); - expect(emptyCycle.viewableAtMs).toBeUndefined(); + 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, @@ -144,10 +144,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 }); }); @@ -162,7 +162,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); @@ -193,8 +193,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', () => { @@ -207,7 +207,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( @@ -227,11 +227,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', @@ -259,7 +259,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); @@ -291,10 +291,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, @@ -302,14 +302,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'); @@ -332,23 +332,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(store.snapshot().callbackIssues.at(-1)).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); @@ -367,8 +367,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', () => { @@ -402,11 +402,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); }); }); 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..811be1f38 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 @@ -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`; }); } diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 7cd988866..7dc51dd9b 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; function apsRenderer() { - const bid = envelope.seatbid[0].bid[0]; + const bid = envelope.seatbid[0]!.bid[0]!; return { type: 'aps' as const, version: 1 as const, @@ -30,34 +30,50 @@ const DEFAULT_BUNDLE_MANIFEST = { /** Loose bid shape used by the requestBids shim tests. */ interface TestBid { bidder: string; - params?: Record; + params?: Record | undefined; } /** Loose ad unit shape used by the requestBids shim tests. */ interface TestAdUnit { - code?: string; - bids?: TestBid[]; + code?: string | undefined; + bids?: TestBid[] | undefined; +} + +function trustedServerBid(unit: TestAdUnit): TestBid & { params: Record } { + const bid = unit.bids?.find((candidate) => candidate.bidder === 'trustedServer'); + if (!bid?.params) throw new Error('expected a trustedServer bid with params'); + return bid as TestBid & { params: Record }; } /** Window properties the prebid shim reads and writes in these tests. */ interface PrebidTestWindow { pbjs?: unknown; - tsjs?: unknown; + tsjs?: Partial | undefined; googletag?: unknown; - __tsjs_prebid?: Record; - __tsjsPrebidShimInstalled?: boolean; + __tsjs_prebid?: Record | undefined; + __tsjsPrebidShimInstalled?: boolean | undefined; __tsjs_prebid_bundle?: unknown; - __tsjs_prebid_diagnostics?: { - userIdModules?: { - includedModules: string[]; - configuredUserIdNames: string[]; - missingConfiguredUserIdNames: string[]; - }; - }; + __tsjs_prebid_diagnostics?: + | { + userIdModules?: + | { + includedModules: string[]; + configuredUserIdNames: string[]; + missingConfiguredUserIdNames: string[]; + } + | undefined; + } + | undefined; } const testWindow = window as unknown as PrebidTestWindow; +function apsPrebidRenderers() { + const registry = testWindow.tsjs?.apsPrebidRenderers; + if (!registry) throw new Error('expected the APS Prebid renderer registry to be installed'); + return registry; +} + /** Argument type accepted by the shimmed `pbjs.requestBids`. */ type RequestBidsArg = Parameters['requestBids']>[0]; @@ -72,7 +88,7 @@ interface TestAdapterSpec { ) => { method: string; url: string; - data: Record; + data: string; options: Record; }; interpretResponse: ( @@ -104,7 +120,10 @@ const { const mockMarkWinningBidAsUsed = vi.fn(); const mockOnEvent = vi.fn(); const mockGetUserIdsAsEids = vi.fn( - () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> + (): Array<{ + source: string; + uids?: Array<{ id: string; atype?: number; ext?: Record }>; + }> => [] ); const mockGetConfig = vi.fn(); const mockRemoveAdUnit = vi.fn((adUnitCode?: string | string[]) => { @@ -113,7 +132,7 @@ const { return; } const codes = new Set(Array.isArray(adUnitCode) ? adUnitCode : [adUnitCode]); - mockPbjs.adUnits = mockPbjs.adUnits.filter((unit) => !codes.has(unit.code)); + mockPbjs.adUnits = mockPbjs.adUnits.filter((unit) => !unit.code || !codes.has(unit.code)); }); const mockPbjs: { setConfig: typeof mockSetConfig; @@ -124,7 +143,7 @@ const { getConfig: typeof mockGetConfig; removeAdUnit: ReturnType; adUnits: TestAdUnit[]; - setTargetingForGPTAsync?: (adUnitCodes?: string[]) => void; + setTargetingForGPTAsync?: ((adUnitCodes?: string[]) => void) | undefined; [key: string]: unknown; } = { setConfig: mockSetConfig, @@ -177,6 +196,7 @@ import { installRefreshHandler, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; +import type { TsjsApi } from '../../../src/core/types'; import { log } from '../../../src/core/log'; import envelope from '../../fixtures/aps-renderer-v1.json'; @@ -335,8 +355,8 @@ describe('prebid/auctionBidsToPrebidBids', () => { const result = auctionBidsToPrebidBids(auctionBids, []); expect(result).toHaveLength(1); - expect(result[0].requestId).toBe('div-gpt-2'); - expect(result[0].cpm).toBe(2.0); + expect(result[0]!.requestId).toBe('div-gpt-2'); + expect(result[0]!.cpm).toBe(2.0); }); it('handles multiple bids across different impids', () => { @@ -370,8 +390,8 @@ describe('prebid/auctionBidsToPrebidBids', () => { const result = auctionBidsToPrebidBids(auctionBids, bidRequests); expect(result).toHaveLength(2); - expect(result[0].requestId).toBe('req-a'); - expect(result[1].requestId).toBe('req-b'); + expect(result[0]!.requestId).toBe('req-a'); + expect(result[1]!.requestId).toBe('req-b'); }); }); @@ -430,7 +450,7 @@ describe('prebid/installPrebidNpm', () => { trustedServerRenderer: renderer, }); - const entry = testWindow.tsjs.apsPrebidRenderers['prebid-generated-ad-id']; + const entry = apsPrebidRenderers()['prebid-generated-ad-id']!; expect(entry).toEqual( expect.objectContaining({ adUnitCode: 'div-aps', @@ -489,7 +509,7 @@ describe('prebid/installPrebidNpm', () => { }; bidResponseListener!(delivered); - const entry = testWindow.tsjs.apsPrebidRenderers['stripped-field-ad-id']; + const entry = apsPrebidRenderers()['stripped-field-ad-id']; expect(entry).toEqual( expect.objectContaining({ adUnitCode: 'div-aps', renderer, markWinner: expect.any(Function) }) ); @@ -538,7 +558,7 @@ describe('prebid/installPrebidNpm', () => { }); } - const registry = testWindow.tsjs.apsPrebidRenderers; + const registry = apsPrebidRenderers(); expect(registry['shared-imp-ad-id-0']).toEqual( expect.objectContaining({ renderer: firstRenderer }) ); @@ -565,7 +585,7 @@ describe('prebid/installPrebidNpm', () => { requestId: 'req-reused', trustedServerRenderer: apsRenderer(), }); - expect(testWindow.tsjs.apsPrebidRenderers['surviving-field-ad-id']).toBeDefined(); + expect(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. @@ -578,7 +598,7 @@ describe('prebid/installPrebidNpm', () => { requestId: 'req-reused', meta: { advertiserDomains: [] }, }); - expect(testWindow.tsjs.apsPrebidRenderers['reused-request-ad-id']).toBeUndefined(); + expect(apsPrebidRenderers()['reused-request-ad-id']).toBeUndefined(); }); it('registers and scrubs on bidAccepted before later events can observe the descriptor', () => { @@ -621,7 +641,7 @@ describe('prebid/installPrebidNpm', () => { }; bidAcceptedListener!(accepted); - expect(testWindow.tsjs.apsPrebidRenderers['accepted-ad-id']).toEqual( + expect(apsPrebidRenderers()['accepted-ad-id']).toEqual( expect.objectContaining({ adUnitCode: 'div-aps', renderer }) ); expect(accepted).not.toHaveProperty('trustedServerRenderer'); @@ -630,9 +650,7 @@ describe('prebid/installPrebidNpm', () => { // 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(apsPrebidRenderers()['accepted-ad-id']).toEqual(expect.objectContaining({ renderer })); expect(warnSpy).not.toHaveBeenCalled(); }); @@ -666,7 +684,7 @@ describe('prebid/installPrebidNpm', () => { meta: 'corrupted', trustedServerRenderer: apsRenderer(), }); - expect(testWindow.tsjs.apsPrebidRenderers['corrupt-meta-with-field-ad-id']).toBeDefined(); + expect(apsPrebidRenderers()['corrupt-meta-with-field-ad-id']).toBeDefined(); }); it('does not register malformed or non-trusted APS renderer capabilities', () => { @@ -727,7 +745,7 @@ describe('prebid/installPrebidNpm', () => { it('reports the User ID modules selected by the generated bundle', () => { installPrebidNpm(); - expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ + expect(testWindow.__tsjs_prebid_diagnostics!.userIdModules).toEqual({ includedModules: ['sharedIdSystem'], configuredUserIdNames: [], missingConfiguredUserIdNames: [], @@ -744,7 +762,7 @@ describe('prebid/installPrebidNpm', () => { mockPbjs.requestBids({ adUnits: [] }); mockPbjs.requestBids({ adUnits: [] }); - expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ + expect(testWindow.__tsjs_prebid_diagnostics!.userIdModules).toEqual({ includedModules: ['sharedIdSystem'], configuredUserIdNames: ['pairId', 'sharedId'], missingConfiguredUserIdNames: ['pairId'], @@ -780,7 +798,7 @@ describe('prebid/installPrebidNpm', () => { installPrebidNpm(); mockPbjs.requestBids({ adUnits: [] }); - expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ + expect(testWindow.__tsjs_prebid_diagnostics!.userIdModules).toEqual({ includedModules: [], configuredUserIdNames: ['pairId', 'sharedId'], missingConfiguredUserIdNames: [], @@ -800,7 +818,7 @@ describe('prebid/installPrebidNpm', () => { describe('adapter spec', () => { function getAdapterSpec(): TestAdapterSpec { installPrebidNpm(); - return mockRegisterBidAdapter.mock.calls[0][2] as TestAdapterSpec; + return mockRegisterBidAdapter.mock.calls[0]![2] as TestAdapterSpec; } it('isBidRequestValid always returns true', () => { @@ -949,7 +967,7 @@ describe('prebid/installPrebidNpm', () => { it('buildRequests uses custom endpoint when configured', () => { mockRegisterBidAdapter.mockClear(); installPrebidNpm({ endpoint: '/custom/auction' }); - const spec = mockRegisterBidAdapter.mock.calls[0][2]; + const spec = mockRegisterBidAdapter.mock.calls[0]![2]; const result = spec.buildRequests([ { @@ -1065,8 +1083,8 @@ describe('prebid/installPrebidNpm', () => { 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'); + expect(bidsA[0]!.requestId).toBe('bid-a'); + expect(bidsB[0]!.requestId).toBe('bid-b'); }); }); @@ -1086,10 +1104,10 @@ describe('prebid/installPrebidNpm', () => { 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']); + const tsBid = trustedServerBid(adUnits[0]!); + expect(tsBid.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(); @@ -1101,7 +1119,7 @@ describe('prebid/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; + const tsCount = adUnits[0]!.bids.filter((b: TestBid) => b.bidder === 'trustedServer').length; expect(tsCount).toBe(1); }); @@ -1118,13 +1136,12 @@ describe('prebid/installPrebidNpm', () => { ]; 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({ + const tsBid = trustedServerBid(adUnits[0]!); + expect(tsBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, rubicon: { accountId: 'abc' }, }); - expect(adUnits[0].bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); + 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', () => { @@ -1147,10 +1164,8 @@ describe('prebid/installPrebidNpm', () => { // 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({ + const tsBid = trustedServerBid(adUnits[0]!); + expect(tsBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, rubicon: { accountId: 'abc' }, }); @@ -1162,19 +1177,19 @@ describe('prebid/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'); + 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[]; + ] as unknown as TestAdUnit[]; expect(() => pbjs.requestBids({ adUnits } as unknown as RequestBidsArg)).not.toThrow(); - expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); + expect(adUnits[0]!.bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); }); it('includes zone from mediaTypes.banner.name in trustedServer params', () => { @@ -1194,10 +1209,10 @@ describe('prebid/installPrebidNpm', () => { ]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid0 = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + const tsBid0 = trustedServerBid(adUnits[0]!); expect(tsBid0.params.zone).toBe('header'); - const tsBid1 = adUnits[1].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + const tsBid1 = trustedServerBid(adUnits[1]!); expect(tsBid1.params.zone).toBe('fixed_bottom'); }); @@ -1213,7 +1228,7 @@ describe('prebid/installPrebidNpm', () => { ]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + const tsBid = trustedServerBid(adUnits[0]!); expect(tsBid.params.zone).toBeUndefined(); }); @@ -1223,7 +1238,7 @@ describe('prebid/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; + const tsBid = trustedServerBid(adUnits[0]!); expect(tsBid.params.zone).toBeUndefined(); }); @@ -1243,14 +1258,14 @@ describe('prebid/installPrebidNpm', () => { pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - let tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + let tsBid = trustedServerBid(adUnits[0]!); expect(tsBid.params.zone).toBe('header'); expect(tsBid.params.custom).toBe('keep'); - delete adUnits[0].mediaTypes.banner.name; + delete (adUnits[0]!.mediaTypes.banner as { name?: string }).name; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + tsBid = trustedServerBid(adUnits[0]!); expect(tsBid.params.zone).toBeUndefined(); expect(tsBid.params.custom).toBe('keep'); }); @@ -1261,7 +1276,7 @@ describe('prebid/installPrebidNpm', () => { mockPbjs.adUnits = [{ bids: [{ bidder: 'openx', params: {} }] }] as TestAdUnit[]; pbjs.requestBids({} as RequestBidsArg); - const hasTsBidder = (mockPbjs.adUnits[0].bids ?? []).some( + const hasTsBidder = (mockPbjs.adUnits[0]!.bids ?? []).some( (b: TestBid) => b.bidder === 'trustedServer' ); expect(hasTsBidder).toBe(true); @@ -1860,7 +1875,7 @@ describe('prebid/installRefreshHandler', () => { expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); expect(originalRefresh).not.toHaveBeenCalled(); - const bidsBackHandler = mockRequestBids.mock.calls[0][0].bidsBackHandler; + const bidsBackHandler = mockRequestBids.mock.calls[0]![0].bidsBackHandler; bidsBackHandler(); expect(setTargetingForGPTAsync).toHaveBeenCalled(); @@ -2140,7 +2155,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { installedGptSlots = slots; for (const slot of slots) { if (!slot || typeof slot !== 'object') continue; - const originalGetTargeting = slot.getTargeting?.bind(slot); + const getTargeting = slot.getTargeting; + const originalGetTargeting = + typeof getTargeting === 'function' + ? (getTargeting as (key: string) => unknown[]).bind(slot) + : undefined; slot.getTargeting = (key: string) => { const deliveryAdId = deliveryAdIds.get(slot); if (key === 'hb_adid' && deliveryAdId) return [deliveryAdId]; @@ -2161,11 +2180,20 @@ describe('prebid publisher snapshots and delivery refreshes', () => { return { originalRefresh, pubads }; } - function refreshAdUnitFromLastRequest(): - | (Record & { code?: string; bids?: TestBid[] }) - | undefined { + function refreshAdUnitFromLastRequest(): Record & { + code?: string; + bids: TestBid[]; + } { const lastCall = mockRequestBids.mock.calls[mockRequestBids.mock.calls.length - 1]; - return lastCall?.[0]?.adUnits?.[0]; + const unit = lastCall?.[0]?.adUnits?.[0]; + if (!unit?.bids) throw new Error('expected the last Prebid request to contain bids'); + return unit as Record & { code?: string; bids: TestBid[] }; + } + + function refreshBidFromLastRequest(index = 0): TestBid & { params: Record } { + const bid = refreshAdUnitFromLastRequest().bids[index]; + if (!bid?.params) throw new Error(`expected refresh bid ${index} to contain params`); + return bid as TestBid & { params: Record }; } function completePublisherAuction( @@ -2183,7 +2211,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; if (options.applyTargeting !== false) { const slot = installedGptSlots.find((candidate) => { - const elementId = candidate?.getSlotElementId?.(); + const getSlotElementId = candidate?.getSlotElementId; + const elementId = + typeof getSlotElementId === 'function' + ? (getSlotElementId as () => string).call(candidate) + : undefined; return elementId === unit.code || elementId === `${unit.code}-container`; }); if (slot) deliveryAdIds.set(slot, adId); @@ -2277,9 +2309,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }, ], } as unknown as RequestBidsArg); - serverParams.placement.rules[0].label = 'changed-rule'; + serverParams.placement.rules[0]!.label = 'changed-rule'; serverParams.placement.sizes.push(999); - browserParams.groups[0].values[0] = 'changed-value'; + browserParams.groups[0]!.values[0] = 'changed-value'; pubads.refresh([slot]); @@ -2305,10 +2337,18 @@ describe('prebid publisher snapshots and delivery refreshes', () => { const firstRefreshBids = refreshAdUnitFromLastRequest().bids; expect(firstRefreshBids).toEqual(expectedBids); - firstRefreshBids[0].params.bidderParams.exampleServer.placement.rules[0].label = + const mutableServerParams = firstRefreshBids[0]!.params as { + bidderParams: { + exampleServer: { placement: { rules: Array<{ label: string }>; sizes: number[] } }; + }; + }; + const mutableBrowserParams = firstRefreshBids[1]!.params as { + groups: Array<{ values: string[] }>; + }; + mutableServerParams.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'; + mutableServerParams.bidderParams.exampleServer.placement.sizes.push(777); + mutableBrowserParams.groups[0]!.values[0] = 'changed-refresh-value'; pubads.refresh([slot]); expect(refreshAdUnitFromLastRequest().bids).toEqual(expectedBids); @@ -2335,13 +2375,13 @@ describe('prebid publisher snapshots and delivery refreshes', () => { ], } as unknown as RequestBidsArg); pubads.refresh([slot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: { exampleServer: { placement: 'one' } }, zone: 'example-zone-one', }); pubads.refresh([slot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: { exampleServer: { placement: 'one' } }, zone: 'example-zone-one', }); @@ -2357,7 +2397,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } as unknown as RequestBidsArg); pubads.refresh([slot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: { exampleServer: { placement: 'two' } }, zone: 'example-zone-two', }); @@ -2405,15 +2445,15 @@ describe('prebid publisher snapshots and delivery refreshes', () => { ]; pubads.refresh([slotOne]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ exampleServer: { placement: 'one' }, }); pubads.refresh([slotTwo]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ exampleServer: { placement: 'two' }, }); pubads.refresh([globalSlot]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ exampleFallback: { placement: 'global' }, }); }); @@ -2491,24 +2531,24 @@ describe('prebid publisher snapshots and delivery refreshes', () => { })), } as unknown as RequestBidsArg); (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit( - codes[0] + codes[0]! ); (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit([ - codes[1], + 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] }, + pubads.refresh([slots[0]!]); + expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: {} }); + pubads.refresh([slots[1]!]); + expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: {} }); + pubads.refresh([slots[2]!]); + expect(refreshBidFromLastRequest().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: {} }); + pubads.refresh([slots[2]!]); + expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: {} }); }); it('bounds snapshots with LRU eviction while retaining a recently refreshed entry', () => { @@ -2552,9 +2592,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } as unknown as RequestBidsArg); pubads.refresh([oldestSlot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: {} }); pubads.refresh([activeSlot]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ exampleServer: { placement: capacity - 1 }, }); }); @@ -2571,7 +2611,15 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; testWindow.tsjs = { - adSlots: [{ div_id: 'example-covered-two', formats: [[300, 250]], targeting: {} }], + adSlots: [ + { + id: 'example-covered-two', + div_id: 'example-covered-two', + gam_unit_path: '/example/covered-two', + formats: [[300, 250]], + targeting: {}, + }, + ], }; const { originalRefresh, pubads } = installGpt([slotOne, slotTwo]); mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); @@ -2708,10 +2756,10 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(mockRequestBids).toHaveBeenCalledTimes(3); expect( - mockRequestBids.mock.calls[1][0].adUnits.map((unit: { code?: string }) => unit.code) + 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) + 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'); @@ -2878,7 +2926,12 @@ describe('prebid publisher snapshots and delivery refreshes', () => { adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], bidsBackHandler: () => { ( - pbjs as unknown as { setTargetingForGPTAsync: (codes?: string[]) => void } + pbjs as unknown as { + setTargetingForGPTAsync: ( + codes?: string[] | null, + customSlotMatching?: () => (slot: unknown) => boolean + ) => void; + } ).setTargetingForGPTAsync(null, () => () => true); pubads.refresh([slot]); }, @@ -3254,8 +3307,8 @@ describe('prebid publisher snapshots and delivery refreshes', () => { vi.advanceTimersByTime(640); expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['example-missing-refresh-callback']); - expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] + expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]!).toBeLessThan( + originalRefresh.mock.invocationCallOrder[0]! ); expect(originalRefresh).toHaveBeenCalledTimes(1); expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); @@ -3288,8 +3341,8 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(setTargetingForGPTAsync).toHaveBeenCalledTimes(1); expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['example-late-refresh-callback']); - expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] + expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]!).toBeLessThan( + originalRefresh.mock.invocationCallOrder[0]! ); expect(originalRefresh).toHaveBeenCalledTimes(1); } finally { @@ -3450,7 +3503,7 @@ describe('prebid/client-side bidders', () => { ]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + const tsBid = trustedServerBid(adUnits[0]!); expect(tsBid).toBeDefined(); // rubicon should NOT be in bidderParams — it runs client-side expect(tsBid.params.bidderParams).toEqual({ @@ -3475,10 +3528,10 @@ describe('prebid/client-side bidders', () => { 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; + 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(); + expect(adUnits[0]!.bids.find((b: TestBid) => b.bidder === 'appnexus')).toBeUndefined(); }); it('handles multiple client-side bidders', () => { @@ -3497,16 +3550,16 @@ describe('prebid/client-side bidders', () => { ]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + const tsBid = trustedServerBid(adUnits[0]!); // 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(); + 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', () => { @@ -3523,7 +3576,7 @@ describe('prebid/client-side bidders', () => { ]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + const tsBid = trustedServerBid(adUnits[0]!); expect(tsBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, rubicon: { accountId: 'abc' }, @@ -3545,7 +3598,7 @@ describe('prebid/client-side bidders', () => { ]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + const tsBid = trustedServerBid(adUnits[0]!); expect(tsBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, rubicon: { accountId: 'abc' }, @@ -3568,7 +3621,7 @@ describe('prebid/client-side bidders', () => { 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; + const tsBid = trustedServerBid(adUnits[0]!); expect(tsBid).toBeDefined(); expect(tsBid.params.bidderParams).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..e7906f111 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 @@ -71,7 +71,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`; }); } 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..f36a1a500 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,6 +1,7 @@ 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; @@ -16,10 +17,10 @@ describe('Beacon Guard', () => { // 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', @@ -130,7 +131,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'); }); 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/scripts/dispatch-workflow-run.mjs b/scripts/dispatch-workflow-run.mjs new file mode 100644 index 000000000..871d907e0 --- /dev/null +++ b/scripts/dispatch-workflow-run.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; + +const POLL_INTERVAL_MS = 2_000; +const POLL_TIMEOUT_MS = 120_000; + +function fail(message) { + throw new Error(`[dispatch-workflow-run] ${message}`); +} + +function run(command, args, options = {}) { + try { + return execFileSync(command, args, { + cwd: options.cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch (error) { + const stderr = + error && typeof error === "object" && "stderr" in error + ? error.stderr + : ""; + fail( + `${command} ${args.join(" ")} failed${stderr ? `: ${String(stderr).trim()}` : ""}`, + ); + } +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function parseInputs(argumentsList) { + const inputs = new Map(); + for (const argument of argumentsList) { + const separator = argument.indexOf("="); + if (separator <= 0) + fail(`workflow input must use key=value syntax: ${argument}`); + const key = argument.slice(0, separator); + const value = argument.slice(separator + 1); + if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key) || value.length === 0) { + fail(`invalid workflow input: ${argument}`); + } + if (inputs.has(key)) fail(`duplicate workflow input: ${key}`); + inputs.set(key, value); + } + return inputs; +} + +function remoteShaForRef(ref) { + const escapedRef = ref.replace(/^refs\/(heads|tags)\//, ""); + const output = run("git", [ + "ls-remote", + "--heads", + "--tags", + "origin", + `refs/heads/${escapedRef}`, + `refs/tags/${escapedRef}`, + `refs/tags/${escapedRef}^{}`, + ]); + const rows = output + .split("\n") + .filter(Boolean) + .map((row) => row.split(/\s+/, 2)); + if (rows.length === 0) fail(`ref is not pushed to origin: ${ref}`); + const dereferencedTag = rows.find(([, remoteRef]) => + remoteRef?.endsWith("^{}"), + ); + return (dereferencedTag ?? rows[0])?.[0]; +} + +function listRuns(workflow) { + const output = run("gh", [ + "run", + "list", + "--workflow", + workflow, + "--event", + "workflow_dispatch", + "--limit", + "100", + "--json", + "databaseId,displayTitle,headBranch,headSha,createdAt", + ]); + try { + return JSON.parse(output); + } catch (error) { + fail( + `gh returned invalid run JSON: ${error instanceof Error ? error.message : error}`, + ); + } +} + +function matchingRuns(runs, evidenceId, sha, dispatchedAfter) { + return runs.filter((candidate) => { + const createdAt = Date.parse(candidate.createdAt); + return ( + candidate.headSha === sha && + candidate.displayTitle?.includes(evidenceId) && + Number.isFinite(createdAt) && + createdAt >= dispatchedAfter + ); + }); +} + +async function main() { + const [workflow, ref, ...inputArguments] = process.argv.slice(2); + if (!workflow || !ref) { + fail( + "usage: dispatch-workflow-run.mjs key=value [...]", + ); + } + const inputs = parseInputs(inputArguments); + const evidenceId = inputs.get("evidence_id"); + if (!evidenceId) fail("required workflow input is missing: evidence_id"); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/.test(evidenceId)) { + fail("evidence_id must be 8-128 URL-safe characters"); + } + + const localSha = run("git", ["rev-parse", `${ref}^{commit}`]); + const remoteSha = remoteShaForRef(ref); + if (localSha !== remoteSha) { + fail( + `ref is not pushed at the local commit: local ${localSha}, origin ${remoteSha}`, + ); + } + + const existing = listRuns(workflow).filter((runRecord) => + runRecord.displayTitle?.includes(evidenceId), + ); + if (existing.length > 0) + fail(`evidence_id has already been used: ${evidenceId}`); + + const dispatchedAfter = Date.now() - 5_000; + const dispatchArguments = ["workflow", "run", workflow, "--ref", ref]; + for (const [key, value] of inputs) + dispatchArguments.push("-f", `${key}=${value}`); + run("gh", dispatchArguments); + + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + const matches = matchingRuns( + listRuns(workflow), + evidenceId, + localSha, + dispatchedAfter, + ); + if (matches.length > 1) + fail(`more than one workflow run matched evidence_id ${evidenceId}`); + if (matches.length === 1) { + const runId = matches[0].databaseId; + if (!Number.isSafeInteger(runId) || runId <= 0) + fail("matched run has an invalid numeric id"); + process.stdout.write(`${runId}\n`); + return; + } + await sleep(POLL_INTERVAL_MS); + } + fail(`timed out waiting for workflow run with evidence_id ${evidenceId}`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : error}\n`); + process.exitCode = 1; +}); diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index 714de510b..debedff8d 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -9,7 +9,7 @@ # - Docker running # - Viceroy installed: cargo install viceroy --version 0.17.0 --locked --force # - wasm32-wasip1 target: rustup target add wasm32-wasip1 -# - Node.js with npx available +# - Node.js with npm available # set -euo pipefail @@ -20,12 +20,30 @@ ORIGIN_PORT="${INTEGRATION_ORIGIN_PORT:-8888}" BROWSER_DIR="crates/trusted-server-integration-tests/browser" TSJS_LIB_DIR="crates/trusted-server-js/lib" NODE_VERSION="$(grep '^nodejs ' .tool-versions | awk '{print $2}')" +FRAMEWORKS_VALUE="${TS_BROWSER_FRAMEWORKS:-nextjs wordpress}" +FRAMEWORKS_VALUE="${FRAMEWORKS_VALUE//,/ }" +read -r -a FRAMEWORKS <<< "$FRAMEWORKS_VALUE" if [ -z "$NODE_VERSION" ]; then echo "Failed to detect Node.js version from .tool-versions" >&2 exit 1 fi +if [ "${#FRAMEWORKS[@]}" -eq 0 ]; then + echo "TS_BROWSER_FRAMEWORKS must select at least one framework" >&2 + exit 1 +fi + +for framework in "${FRAMEWORKS[@]}"; do + case "$framework" in + nextjs|wordpress) ;; + *) + echo "Unsupported browser framework: $framework" >&2 + exit 1 + ;; + esac +done + # --- Build WASM binary --- echo "==> Building WASM binary (origin=http://127.0.0.1:$ORIGIN_PORT)..." TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" \ @@ -40,29 +58,30 @@ INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" ./scripts/generate-integration-viceroy-co GENERATED_VICEROY_CONFIG_PATH="$REPO_ROOT/target/integration-test-artifacts/configs/viceroy.toml" # --- Build Docker images --- -echo "==> Building WordPress test container..." -docker build -t test-wordpress:latest \ - crates/trusted-server-integration-tests/fixtures/frameworks/wordpress/ - -echo "==> Building Next.js test container..." -docker build \ - --build-arg NODE_VERSION="$NODE_VERSION" \ - -t test-nextjs:latest \ - crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/ +for framework in "${FRAMEWORKS[@]}"; do + if [ "$framework" = "wordpress" ]; then + echo "==> Building WordPress test container..." + docker build -t test-wordpress:latest \ + crates/trusted-server-integration-tests/fixtures/frameworks/wordpress/ + else + echo "==> Building Next.js test container..." + docker build \ + --build-arg NODE_VERSION="$NODE_VERSION" \ + -t test-nextjs:latest \ + crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/ + fi +done # --- Install Playwright --- echo "==> Installing Playwright dependencies..." -cd "$REPO_ROOT/$BROWSER_DIR" -npm ci -npx playwright install chromium +npm --prefix "$BROWSER_DIR" ci +npm --prefix "$BROWSER_DIR" exec -- playwright install chromium # --- Build browser-side Trusted Server and external Prebid fixtures --- echo "==> Building TSJS browser fixtures..." -cd "$REPO_ROOT/$TSJS_LIB_DIR" -npm ci -npm run build -npm run build:prebid-external -cd "$REPO_ROOT/$BROWSER_DIR" +npm --prefix "$TSJS_LIB_DIR" ci +npm --prefix "$TSJS_LIB_DIR" run build +npm --prefix "$TSJS_LIB_DIR" run build:prebid-external # --- Export env vars for global-setup.ts --- export WASM_BINARY_PATH="$REPO_ROOT/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm" @@ -80,15 +99,17 @@ stop_matching_containers() { } cleanup() { - stop_matching_containers test-nextjs:latest - stop_matching_containers test-wordpress:latest + for framework in "${FRAMEWORKS[@]}"; do + stop_matching_containers "test-$framework:latest" + done } trap cleanup EXIT # --- Run tests for each framework --- -for framework in nextjs wordpress; do +for framework in "${FRAMEWORKS[@]}"; do echo "==> Running Playwright tests for $framework..." - TEST_FRAMEWORK="$framework" npx playwright test "$@" + TEST_FRAMEWORK="$framework" npm --prefix "$BROWSER_DIR" exec -- \ + playwright test --config "$BROWSER_DIR/playwright.config.ts" "$@" done echo "==> All browser tests passed." From d08e3051a441900ee8400cc66b50d461891dee14 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:56:46 -0700 Subject: [PATCH 014/194] Attach Ubuntu baseline evidence --- .../performance/aps-tsjs-prechange.json | 90 ++++++++++++++----- 1 file changed, 68 insertions(+), 22 deletions(-) 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 index 77e672aa7..12f8df903 100644 --- 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 @@ -3,14 +3,14 @@ "mode": "baseline", "source": { "ref": "spec/aps-tsjs-resilience-design", - "sha": "1b9753efa01a7985ae8ea804b1997a3cf3b3a7b4" + "sha": "88f1432e33f310a202177feb656eba21ff0173de" }, "environment": { "node": "v24.12.0", "npm": "11.6.2", "typescript": "5.9.3", "chromium": "145.0.7632.6", - "ciMachineClass": "local:darwin-arm64", + "ciMachineClass": "github-hosted:ubuntu-24.04", "fixture": "tsjs-core-placeholder-v1" }, "sampling": { @@ -20,14 +20,21 @@ }, "bundles": { "minimal": { - "files": ["tsjs-core.js"], + "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"], + "files": [ + "tsjs-core.js", + "tsjs-creative.js", + "tsjs-gpt.js", + "tsjs-prebid.js" + ], "rawBytes": 107265, "gzipBytes": 33428, "brotliBytes": 25236, @@ -58,29 +65,68 @@ "performance": { "bootToFirstDisplayMs": { "samples": [ - 11.300000011920929, 11.400000005960464, 11, 10.599999994039536, 11.599999994039536, - 13.199999988079071, 11.799999982118607, 11.100000023841858, 11, 11.799999982118607, 11.5, - 10.5, 10.800000011920929, 11.899999976158142, 11.199999988079071, 10.600000023841858, - 10.799999982118607, 11.099999994039536, 10.800000011920929, 10.699999988079071, 10.5, - 11.200000017881393, 10.599999994039536, 10.5, 10.800000011920929, 10.5, 10.300000011920929, - 10.199999988079071, 10.800000011920929, 10.700000017881393, 10.299999982118607, - 10.800000011920929, 10.800000011920929, 11.099999994039536, 15.699999988079071, - 10.399999976158142, 10.599999994039536, 10.5, 10.800000011920929, 10.599999994039536, - 10.699999988079071, 10.599999994039536, 11.599999994039536, 11.699999988079071, - 11.599999994039536, 10.5, 10.699999988079071, 10.400000005960464, 11.099999994039536, - 11.700000017881393 + 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": 11.700000017881393 + "p90": 26 }, "retainedHeapBytes": { - "afterBoot": 1210116, - "afterFirstRender": 1213316, - "afterRefresh": 1213316, - "afterSpaNavigation": 1220364 + "afterBoot": 1208816, + "afterFirstRender": 1212016, + "afterRefresh": 1212016, + "afterSpaNavigation": 1219472 } }, "evidence": { - "evidenceId": null, - "workflowRunId": null + "evidenceId": "aps-tsjs-baseline-88f1432e33f310a202177feb656eba21ff0173de", + "workflowRunId": 31074816129 } } From 54707648499c7cc8d7c5440d84358df32d9ce956 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:16:16 -0700 Subject: [PATCH 015/194] Make APS descriptor a cross-language executable contract --- .github/workflows/test.yml | 6 + .../src/auction/formats.rs | 62 ++- .../src/auction/orchestrator.rs | 4 +- .../trusted-server-core/src/auction/types.rs | 302 ++++++++++- .../src/integrations/adserver_mock.rs | 4 +- .../src/integrations/aps.rs | 510 ++++++++++++++--- .../generated/aps_renderer_validator_v1.js | 138 +++++ crates/trusted-server-core/src/openrtb.rs | 4 +- crates/trusted-server-core/src/publisher.rs | 6 +- crates/trusted-server-js/lib/.prettierignore | 3 +- crates/trusted-server-js/lib/package.json | 2 + .../trusted-server-js/lib/src/core/types.ts | 21 +- .../aps/generated/renderer_validator_v1.ts | 141 +++++ .../lib/src/integrations/aps/render.ts | 137 +---- .../test/contract/aps-renderer-es5.test.mjs | 165 ++++++ .../test/fixtures/aps-renderer-v1-corpus.json | 511 ++++++++++++++++++ .../test/fixtures/aps-renderer-v1.schema.json | 88 +++ .../lib/test/integrations/aps/render.test.ts | 230 ++++++++ scripts/generate-aps-renderer-contract.mjs | 315 +++++++++++ 19 files changed, 2411 insertions(+), 238 deletions(-) create mode 100644 crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js create mode 100644 crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts create mode 100644 crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs create mode 100644 crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json create mode 100644 crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json create mode 100644 scripts/generate-aps-renderer-contract.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 163bf45f3..1e35b59dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -253,6 +253,12 @@ jobs: - name: Lint full TSJS package run: npm run lint + - name: Verify generated APS renderer contract + run: npm run check:aps-contract + + - 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 diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 9419276c8..a55abb3fc 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -365,21 +365,25 @@ 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.record_drop("multiple_render_sources"); + 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!( @@ -519,7 +523,7 @@ pub(crate) fn convert_to_openrtb_response_with_report( mod tests { use super::*; use crate::auction::types::{ - ApsRendererV1, ApsTagType, AuctionResponse, Bid, BidRenderer, BidStatus, + ApsRendererV1, ApsTagType, AuctionResponse, Bid, BidRenderSourceV1, BidStatus, }; use crate::openrtb::{Eid, Uid}; use crate::platform::test_support::noop_services; @@ -1413,7 +1417,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(), @@ -1483,12 +1487,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(), @@ -1501,15 +1505,21 @@ mod tests { })); let result = make_result(bid); - let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) - .expect("should prefer ordinary creative markup"); - let json = response_json(response); - let bid = &json["seatbid"][0]["bid"][0]; - - assert_eq!(bid["adm"], "
Ad
"); + let conversion = + convert_to_openrtb_response_with_report(&result, &settings, &auction_request, false) + .expect("should reject an ambiguous render source"); + let json = response_json(conversion.response); + assert!( + json["seatbid"].as_array().is_none_or(Vec::is_empty), + "should not serialize an ambiguous winner" + ); + assert_eq!(conversion.delivery.dropped_winner_count, 1); assert!( - bid.get("ext").is_none(), - "should omit renderer extension when creative markup wins precedence" + conversion + .delivery + .dropped_winner_reasons + .contains_key("multiple_render_sources"), + "should report the exact ambiguous-source reason" ); } @@ -1522,7 +1532,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(), diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 9ceb629b4..aeda9f7a6 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -1524,7 +1524,7 @@ mod tests { 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, + AuctionResponse, Bid, BidRenderSourceV1, BidStatus, MediaType, PublisherInfo, UserInfo, }; use crate::error::TrustedServerError; use crate::platform::test_support::{ @@ -1739,7 +1739,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(), diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index f1630c346..1287512e7 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -1,9 +1,11 @@ //! Core types for auction requests and responses. +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use edgezero_core::body::Body as EdgeBody; use http::Request; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; +use url::Url; use crate::auction::context::ContextValue; use crate::geo::GeoInfo; @@ -187,7 +189,7 @@ pub enum ApsTagType { /// Version 1 APS renderer descriptor shared with browser clients. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ApsRendererV1 { /// Renderer contract version. pub version: u8, @@ -210,22 +212,306 @@ 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, +} + +/// 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. @@ -239,7 +525,7 @@ pub struct Bid { 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 +553,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. @@ -597,7 +883,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 +917,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(), diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 3c12c35f8..d50c6cde4 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -633,7 +633,7 @@ mod tests { bid_id: Some(bid_id.to_string()), ad_id: None, creative_id: Some(format!("creative-{bid_id}")), - renderer: Some(BidRenderer::Aps(ApsRendererV1 { + renderer: Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: bid_id.to_string(), @@ -830,7 +830,7 @@ mod tests { bid_id: Some("source-bid-id".to_string()), ad_id: Some("bid-impression-id".to_string()), creative_id: Some("source-creative-id".to_string()), - renderer: Some(BidRenderer::Aps(ApsRendererV1 { + renderer: Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "source-bid-id".to_string(), diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 44f4203ef..855373db5 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -18,8 +18,9 @@ use validator::{Validate, ValidationError}; use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; use crate::auction::types::{ - AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionRequest, AuctionResponse, Bid, - BidRenderer, MediaType, + AdSlot, ApsRendererV1, ApsRendererValidationResult, ApsTagType, AuctionContext, AuctionRequest, + AuctionResponse, Bid, BidRenderSourceV1, MediaType, RENDER_DIMENSION_MAX, + classify_aps_renderer_v1, }; use crate::error::TrustedServerError; use crate::integrations::{ @@ -48,50 +49,24 @@ const MAX_PAGE_URL_BYTES: usize = 8192; const MAX_RENDER_ENVELOPE_BYTES: usize = 256 * 1024; const APS_RENDERER_CSP: &str = "default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; script-src 'unsafe-inline' https:; connect-src https:; frame-src https:; img-src https: data:; media-src https: blob:; style-src 'unsafe-inline' https:; font-src https: data:;"; -const APS_RENDERER_DOCUMENT: &str = r#" +const APS_RENDERER_DOCUMENT: &str = concat!( + r#" -"#; +"# +); /// Configuration for the APS `OpenRTB` integration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] @@ -664,7 +640,7 @@ impl ApsAuctionProvider { }) } - fn valid_creative_url(&self, value: &str, publisher_domain: &str) -> bool { + fn valid_creative_url(&self, value: &str, publisher_origin: &str) -> bool { if value.len() > MAX_CREATIVE_URL_BYTES { return false; } @@ -672,14 +648,17 @@ impl ApsAuctionProvider { return false; }; parsed.scheme() == "https" - && parsed - .host_str() - .is_some_and(|host| !host.eq_ignore_ascii_case(publisher_domain)) + && parsed.host_str().is_some() && parsed.username().is_empty() && parsed.password().is_none() + && parsed.origin().ascii_serialization() != publisher_origin } - fn build_renderer(&self, input: ApsRendererInput<'_>) -> Option { + fn build_renderer( + &self, + input: ApsRendererInput<'_>, + publisher_origin: &str, + ) -> Option { let tag_type_value = match input.tag_type { ApsTagType::Iframe => "iframe", ApsTagType::Script => "script", @@ -702,7 +681,7 @@ impl ApsAuctionProvider { if serialized.len() > MAX_RENDER_ENVELOPE_BYTES { return None; } - Some(BidRenderer::Aps(ApsRendererV1 { + let renderer = BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: self.config.account_id.clone(), bid_id: input.bid_id.to_string(), @@ -712,7 +691,11 @@ impl ApsAuctionProvider { aax_response: BASE64_STANDARD.encode(serialized), width: input.width, height: input.height, - })) + }); + let value = serde_json::to_value(&renderer).ok()?; + (classify_aps_renderer_v1(&value, publisher_origin) + == ApsRendererValidationResult::Accepted) + .then_some(renderer) } fn increment_reason(reasons: &mut BTreeMap, reason: &'static str) { @@ -723,13 +706,18 @@ impl ApsAuctionProvider { &self, value: &Json, slots: &HashMap<&str, &AdSlot>, - publisher_domain: &str, + publisher_origin: &str, ) -> Result { let bid_id = value .get("id") .and_then(Json::as_str) - .filter(|value| !value.is_empty()) .ok_or("missing_render_source")?; + if bid_id.is_empty() + || bid_id.len() > 64 + || bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) + { + return Err("invalid_bid_id"); + } let slot_id = value .get("impid") .and_then(Json::as_str) @@ -746,16 +734,19 @@ impl ApsAuctionProvider { { return Err("unsupported_media_type"); } - let width = value - .get("w") - .and_then(Json::as_u64) - .and_then(|value| u32::try_from(value).ok()) - .ok_or("invalid_dimensions")?; - let height = value - .get("h") - .and_then(Json::as_u64) - .and_then(|value| u32::try_from(value).ok()) - .ok_or("invalid_dimensions")?; + let parse_dimension = |field: &str| { + let number = value + .get(field) + .and_then(Json::as_f64) + .filter(|number| number.is_finite() && number.fract() == 0.0 && *number > 0.0) + .ok_or("invalid_dimensions")?; + if number > RENDER_DIMENSION_MAX as f64 { + return Err("dimensions_out_of_range"); + } + u32::try_from(number as u64).map_err(|_| "dimensions_out_of_range") + }; + let width = parse_dimension("w")?; + let height = parse_dimension("h")?; if !Self::compatible_dimensions(slot, width, height) { return Err("invalid_dimensions"); } @@ -767,7 +758,7 @@ impl ApsAuctionProvider { .get("creativeurl") .and_then(Json::as_str) .ok_or("missing_render_source")?; - if !self.valid_creative_url(creative_url, publisher_domain) { + if !self.valid_creative_url(creative_url, publisher_origin) { return Err("invalid_creative_url"); } let tag_type = match ext.get("tagtype").and_then(Json::as_str) { @@ -788,15 +779,18 @@ impl ApsAuctionProvider { return Err("creative_id_too_large"); } let renderer = self - .build_renderer(ApsRendererInput { - bid_id, - creative_id: creative_id.clone(), - tag_type, - creative_url, - price, - width, - height, - }) + .build_renderer( + ApsRendererInput { + bid_id, + creative_id: creative_id.clone(), + tag_type, + creative_url, + price, + width, + height, + }, + publisher_origin, + ) .ok_or("render_payload_too_large")?; let adomain = value .get("adomain") @@ -869,6 +863,14 @@ impl ApsAuctionProvider { let mut selected: HashMap = HashMap::new(); let mut dropped = 0_u64; + let publisher_origin = request + .publisher + .page_url + .as_deref() + .and_then(|page_url| Url::parse(page_url).ok()) + .map(|page_url| page_url.origin().ascii_serialization()) + .unwrap_or_else(|| format!("https://{}", request.publisher.domain)); + for seatbid in seatbids.into_iter().flatten() { let Some(bids) = seatbid.get("bid").and_then(Json::as_array) else { dropped += 1; @@ -876,7 +878,7 @@ impl ApsAuctionProvider { continue; }; for value in bids { - match self.parse_bid(value, &slots, &request.publisher.domain) { + match self.parse_bid(value, &slots, &publisher_origin) { Ok(candidate) => { let replace = selected.get(&candidate.slot_id).is_none_or(|current| { let candidate_price = candidate.price.unwrap_or_default(); @@ -1338,6 +1340,380 @@ mod tests { }) } + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct RendererCorpus { + publisher_origin: String, + base_descriptor: Json, + vectors: Vec, + } + + #[derive(serde::Deserialize)] + struct RendererCorpusVector { + id: String, + expected: String, + operation: Json, + } + + fn corpus_value<'a>(operation: &'a Json, field: &str) -> &'a Json { + operation + .get(field) + .unwrap_or_else(|| panic!("should include corpus operation field {field}")) + } + + fn corpus_string<'a>(operation: &'a Json, field: &str) -> &'a str { + corpus_value(operation, field) + .as_str() + .unwrap_or_else(|| panic!("corpus operation field {field} should be a string")) + } + + fn corpus_usize(operation: &Json, field: &str) -> usize { + corpus_value(operation, field) + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or_else(|| panic!("corpus operation field {field} should be a usize")) + } + + fn set_json_path(root: &mut Json, path: &[Json], value: Json) { + let (segment, tail) = path + .split_first() + .expect("corpus JSON path should not be empty"); + if tail.is_empty() { + if let Some(field) = segment.as_str() { + root.as_object_mut() + .expect("corpus string path should address an object") + .insert(field.to_string(), value); + } else { + let index = segment + .as_u64() + .and_then(|index| usize::try_from(index).ok()) + .expect("corpus numeric path should be a usize"); + let slot = root + .as_array_mut() + .and_then(|array| array.get_mut(index)) + .expect("corpus numeric path should address an array element"); + *slot = value; + } + return; + } + + let child = if let Some(field) = segment.as_str() { + root.as_object_mut() + .and_then(|object| object.get_mut(field)) + .expect("corpus string path should address an object field") + } else { + let index = segment + .as_u64() + .and_then(|index| usize::try_from(index).ok()) + .expect("corpus numeric path should be a usize"); + root.as_array_mut() + .and_then(|array| array.get_mut(index)) + .expect("corpus numeric path should address an array element") + }; + set_json_path(child, tail, value); + } + + fn delete_json_path(root: &mut Json, path: &[Json]) { + let (segment, tail) = path + .split_first() + .expect("corpus JSON path should not be empty"); + if tail.is_empty() { + let field = segment + .as_str() + .expect("corpus delete path should end in an object field"); + root.as_object_mut() + .expect("corpus delete path should address an object") + .remove(field); + return; + } + + let child = if let Some(field) = segment.as_str() { + root.as_object_mut() + .and_then(|object| object.get_mut(field)) + .expect("corpus string path should address an object field") + } else { + let index = segment + .as_u64() + .and_then(|index| usize::try_from(index).ok()) + .expect("corpus numeric path should be a usize"); + root.as_array_mut() + .and_then(|array| array.get_mut(index)) + .expect("corpus numeric path should address an array element") + }; + delete_json_path(child, tail); + } + + fn descriptor_field(descriptor: &mut Json, field: &str, value: Json) { + descriptor + .as_object_mut() + .expect("corpus descriptor should be an object") + .insert(field.to_string(), value); + } + + fn materialize_renderer_corpus_vector( + corpus: &RendererCorpus, + vector: &RendererCorpusVector, + ) -> Json { + let mut descriptor = corpus.base_descriptor.clone(); + let mut envelope: Json = serde_json::from_str(include_str!( + "../../../trusted-server-js/lib/test/fixtures/aps-renderer-v1.json" + )) + .expect("should parse shared APS renderer fixture"); + let operation = &vector.operation; + let kind = corpus_string(operation, "kind"); + let mut encoded_envelope = None; + + match kind { + "none" => {} + "descriptor-delete" => { + descriptor + .as_object_mut() + .expect("corpus descriptor should be an object") + .remove(corpus_string(operation, "field")); + } + "descriptor-set" => descriptor_field( + &mut descriptor, + corpus_string(operation, "field"), + corpus_value(operation, "value").clone(), + ), + "descriptor-repeat" => { + let mut repeated = + corpus_string(operation, "unit").repeat(corpus_usize(operation, "count")); + if let Some(suffix) = operation.get("suffix").and_then(Json::as_str) { + repeated.push_str(suffix); + } + descriptor_field( + &mut descriptor, + corpus_string(operation, "field"), + json!(repeated), + ); + } + "bid-id-repeat" => { + let mut repeated = + corpus_string(operation, "unit").repeat(corpus_usize(operation, "count")); + if let Some(suffix) = operation.get("suffix").and_then(Json::as_str) { + repeated.push_str(suffix); + } + descriptor_field(&mut descriptor, "bidId", json!(repeated)); + set_json_path( + &mut envelope, + &[ + json!("seatbid"), + json!(0), + json!("bid"), + json!(0), + json!("id"), + ], + json!(repeated), + ); + } + "dimension" => { + let field = corpus_string(operation, "field"); + let envelope_field = match field { + "width" => "w", + "height" => "h", + _ => panic!("corpus dimension field should be width or height"), + }; + let value = corpus_value(operation, "value").clone(); + descriptor_field(&mut descriptor, field, value.clone()); + set_json_path( + &mut envelope, + &[ + json!("seatbid"), + json!(0), + json!("bid"), + json!(0), + json!(envelope_field), + ], + value, + ); + } + "dimensions" => { + let width = corpus_value(operation, "width").clone(); + let height = corpus_value(operation, "height").clone(); + descriptor_field(&mut descriptor, "width", width.clone()); + descriptor_field(&mut descriptor, "height", height.clone()); + set_json_path( + &mut envelope, + &[ + json!("seatbid"), + json!(0), + json!("bid"), + json!(0), + json!("w"), + ], + width, + ); + set_json_path( + &mut envelope, + &[ + json!("seatbid"), + json!(0), + json!("bid"), + json!(0), + json!("h"), + ], + height, + ); + } + "creative-url" => { + let value = corpus_string(operation, "value").to_string(); + descriptor_field(&mut descriptor, "creativeUrl", json!(value)); + set_json_path( + &mut envelope, + &[ + json!("seatbid"), + json!(0), + json!("bid"), + json!(0), + json!("ext"), + json!("creativeurl"), + ], + json!(value), + ); + } + "creative-url-bytes" => { + let prefix = "https://creative.example/"; + let bytes = corpus_usize(operation, "bytes"); + let value = format!( + "{prefix}{}", + "a".repeat( + bytes + .checked_sub(prefix.len()) + .expect("corpus URL size should include its prefix") + ) + ); + descriptor_field(&mut descriptor, "creativeUrl", json!(value)); + set_json_path( + &mut envelope, + &[ + json!("seatbid"), + json!(0), + json!("bid"), + json!(0), + json!("ext"), + json!("creativeurl"), + ], + json!(value), + ); + } + "aax-literal" => { + encoded_envelope = Some(corpus_string(operation, "value").to_string()); + } + "aax-bytes" => { + let bytes: Vec = corpus_value(operation, "values") + .as_array() + .expect("corpus byte vector should be an array") + .iter() + .map(|value| { + value + .as_u64() + .and_then(|value| u8::try_from(value).ok()) + .expect("corpus byte vector should contain u8 values") + }) + .collect(); + encoded_envelope = Some(BASE64_STANDARD.encode(bytes)); + } + "aax-raw-json" => { + encoded_envelope = + Some(BASE64_STANDARD.encode(corpus_string(operation, "value").as_bytes())); + } + "aax-decoded-bytes" => { + let mut serialized = + serde_json::to_string(&envelope).expect("should serialize corpus envelope"); + let target = corpus_usize(operation, "bytes"); + serialized.push_str( + &" ".repeat( + target + .checked_sub(serialized.len()) + .expect("corpus decoded size should exceed fixture size"), + ), + ); + encoded_envelope = Some(BASE64_STANDARD.encode(serialized.as_bytes())); + } + "aax-raw-price" => { + let serialized = + serde_json::to_string(&envelope).expect("should serialize corpus envelope"); + let replacement = format!("\"price\":{}", corpus_string(operation, "value")); + let raw = serialized.replacen("\"price\":1.23", &replacement, 1); + assert_ne!(raw, serialized, "should replace the corpus fixture price"); + encoded_envelope = Some(BASE64_STANDARD.encode(raw.as_bytes())); + } + "envelope-set" => { + let path = corpus_value(operation, "path") + .as_array() + .expect("corpus path should be an array"); + set_json_path( + &mut envelope, + path, + corpus_value(operation, "value").clone(), + ); + } + "envelope-delete" => { + let path = corpus_value(operation, "path") + .as_array() + .expect("corpus path should be an array"); + delete_json_path(&mut envelope, path); + } + "duplicate-seat" => { + let seats = envelope + .get_mut("seatbid") + .and_then(Json::as_array_mut) + .expect("corpus fixture should contain a seat array"); + let first = seats + .first() + .cloned() + .expect("corpus fixture should contain one seat"); + seats.push(first); + } + "duplicate-bid" => { + let bids = envelope + .get_mut("seatbid") + .and_then(Json::as_array_mut) + .and_then(|seats| seats.first_mut()) + .and_then(|seat| seat.get_mut("bid")) + .and_then(Json::as_array_mut) + .expect("corpus fixture should contain a bid array"); + let first = bids + .first() + .cloned() + .expect("corpus fixture should contain one bid"); + bids.push(first); + } + _ => panic!("unknown APS renderer corpus operation: {kind}"), + } + + let encoded = encoded_envelope.unwrap_or_else(|| { + BASE64_STANDARD.encode( + serde_json::to_vec(&envelope).expect("should serialize corpus renderer envelope"), + ) + }); + descriptor_field(&mut descriptor, "aaxResponse", json!(encoded)); + descriptor + } + + #[test] + fn aps_renderer_matches_shared_cross_language_contract_corpus() { + let corpus: RendererCorpus = serde_json::from_str(include_str!( + "../../../trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json" + )) + .expect("should parse shared APS renderer corpus"); + assert_eq!( + corpus.publisher_origin, "https://publisher.example", + "should pin the corpus publisher origin" + ); + + for vector in &corpus.vectors { + let descriptor = materialize_renderer_corpus_vector(&corpus, vector); + let actual = classify_aps_renderer_v1(&descriptor, &corpus.publisher_origin).as_str(); + assert_eq!( + actual, vector.expected, + "should match APS renderer corpus vector {}", + vector.id + ); + } + } + fn parse_with_context( provider: &ApsAuctionProvider, response: PlatformResponse, @@ -2304,6 +2680,8 @@ mod tests { let mut uppercase_publisher = request(); uppercase_publisher.publisher.domain = "Creative.Example".to_string(); + uppercase_publisher.publisher.page_url = + Some("https://Creative.Example/article".to_string()); let response = provider.parse_aps_response( &json!({"seatbid": [{"bid": [bid("same-origin", 1.0, "iframe")]}]}), 12, diff --git a/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js b/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js new file mode 100644 index 000000000..a81408244 --- /dev/null +++ b/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js @@ -0,0 +1,138 @@ +// @generated by scripts/generate-aps-renderer-contract.mjs +// schema-sha256: e7ed370e6ccbeb30660b63ae0837dbde6ddd56e81f606a544d37b9d0b99f5d1d +// corpus-sha256: 3aea612e3316e6df4852e80cb3aa8882ca43d842455a22ba29e63fa88291c7b9 +var DESCRIPTOR_KEYS = ["aaxResponse","accountId","bidId","creativeUrl","height","tagType","type","version","width"]; +var DESCRIPTOR_KEYS_WITH_CREATIVE_ID = ["aaxResponse","accountId","bidId","creativeId","creativeUrl","height","tagType","type","version","width"]; +var ENVELOPE_ROOT_KEYS = ["seatbid"]; +var ENVELOPE_SEAT_KEYS = ["bid"]; +var ENVELOPE_BID_KEYS = ["ext","h","id","price","w"]; +var ENVELOPE_EXT_KEYS = ["creativeurl","tagtype"]; +var MAX_ACCOUNT_ID_BYTES = 1024; +var MAX_BID_ID_BYTES = 64; +var MAX_CREATIVE_ID_BYTES = 1024; +var MAX_CREATIVE_URL_BYTES = 4096; +var MAX_RENDER_ENVELOPE_BYTES = 262144; +var MAX_RENDER_ENVELOPE_BASE64_BYTES = 349528; +var STANDARD_BASE64_PATTERN = "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"; +var RENDER_DIMENSION_MIN = 1; +var RENDER_DIMENSION_MAX = 4096; +function apsExactRecord(value, expectedKeys) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + var prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + if (typeof Object.getOwnPropertySymbols === 'function' && Object.getOwnPropertySymbols(value).length !== 0) return false; + var actual = Object.getOwnPropertyNames(value).sort(); + if (actual.length !== expectedKeys.length) return false; + for (var index = 0; index < actual.length; index += 1) { + var propertyName = actual[index]; + if (propertyName === undefined || propertyName !== expectedKeys[index]) return false; + var property = Object.getOwnPropertyDescriptor(value, propertyName); + if (!property || !Object.prototype.hasOwnProperty.call(property, 'value')) return false; + } + return true; +} + +function apsUtf8Length(value) { + return (new TextEncoder()).encode(value).length; +} + +function apsHasAsciiControl(value) { + return /[\x00-\x1f\x7f]/.test(value); +} + +function apsDimensionResult(value) { + if (typeof value !== 'number' || !isFinite(value) || Math.floor(value) !== value || value <= 0) { + return 'invalid_dimensions'; + } + if (value < RENDER_DIMENSION_MIN || value > RENDER_DIMENSION_MAX) { + return 'dimensions_out_of_range'; + } + return 'accepted'; +} + +function apsValidCreativeUrl(value, publisherOrigin) { + try { + var url = new URL(value); + return url.protocol === 'https:' && url.hostname !== '' && url.username === '' && + url.password === '' && url.origin !== publisherOrigin; + } catch (_error) { + return false; + } +} + +function apsDecodeEnvelope(value) { + if (value.length === 0 || value.length > MAX_RENDER_ENVELOPE_BASE64_BYTES || + value.length % 4 !== 0 || !(new RegExp(STANDARD_BASE64_PATTERN)).test(value)) { + return undefined; + } + try { + var binary = atob(value); + if (binary.length > MAX_RENDER_ENVELOPE_BYTES || btoa(binary) !== value) return undefined; + var bytes = new Uint8Array(binary.length); + for (var index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch (_error) { + return undefined; + } +} + +function classifyApsRendererDescriptorV1( + value +) { + var renderer = value; + if (!apsExactRecord(renderer, DESCRIPTOR_KEYS) && + !apsExactRecord(renderer, DESCRIPTOR_KEYS_WITH_CREATIVE_ID)) return 'descriptor_invalid'; + if (renderer.type !== 'aps' || renderer.version !== 1 || + typeof renderer.accountId !== 'string' || renderer.accountId.length === 0 || + apsUtf8Length(renderer.accountId) > MAX_ACCOUNT_ID_BYTES || + typeof renderer.bidId !== 'string' || renderer.bidId.length === 0 || + apsUtf8Length(renderer.bidId) > MAX_BID_ID_BYTES || + apsHasAsciiControl(renderer.bidId)) return 'descriptor_invalid'; + if (Object.prototype.hasOwnProperty.call(renderer, 'creativeId') && + (typeof renderer.creativeId !== 'string' || renderer.creativeId.length === 0 || + apsUtf8Length(renderer.creativeId) > MAX_CREATIVE_ID_BYTES)) return 'descriptor_invalid'; + if (renderer.tagType !== 'iframe' && renderer.tagType !== 'script') return 'descriptor_invalid'; + + var widthResult = apsDimensionResult(renderer.width); + if (widthResult !== 'accepted') return widthResult; + var heightResult = apsDimensionResult(renderer.height); + if (heightResult !== 'accepted') return heightResult; + + if (typeof renderer.creativeUrl !== 'string' || + apsUtf8Length(renderer.creativeUrl) > MAX_CREATIVE_URL_BYTES || + typeof renderer.aaxResponse !== 'string' || + renderer.aaxResponse.length > MAX_RENDER_ENVELOPE_BASE64_BYTES) return 'descriptor_invalid'; + return 'accepted'; +} + +function classifyApsRendererV1( + value, + publisherOrigin +) { + var renderer = value; + var descriptorResult = + classifyApsRendererDescriptorV1(renderer); + if (descriptorResult !== 'accepted') return descriptorResult; + if (!apsValidCreativeUrl(renderer.creativeUrl, publisherOrigin)) return 'descriptor_invalid'; + + var decoded = apsDecodeEnvelope(renderer.aaxResponse); + if (!apsExactRecord(decoded, ENVELOPE_ROOT_KEYS) || !Array.isArray(decoded.seatbid) || + decoded.seatbid.length !== 1) return 'descriptor_invalid'; + var seat = decoded.seatbid[0]; + if (!apsExactRecord(seat, ENVELOPE_SEAT_KEYS) || !Array.isArray(seat.bid) || + seat.bid.length !== 1) return 'descriptor_invalid'; + var bid = seat.bid[0]; + if (!apsExactRecord(bid, ENVELOPE_BID_KEYS) || + !apsExactRecord(bid.ext, ENVELOPE_EXT_KEYS)) return 'descriptor_invalid'; + + var bidWidthResult = apsDimensionResult(bid.w); + if (bidWidthResult !== 'accepted') return bidWidthResult; + var bidHeightResult = apsDimensionResult(bid.h); + if (bidHeightResult !== 'accepted') return bidHeightResult; + if (bid.id !== renderer.bidId || bid.w !== renderer.width || bid.h !== renderer.height || + bid.ext.creativeurl !== renderer.creativeUrl || bid.ext.tagtype !== renderer.tagType || + typeof bid.price !== 'number' || !isFinite(bid.price) || bid.price < 0) { + return 'descriptor_invalid'; + } + return 'accepted'; +} diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index 4aded7488..c27a5c988 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::auction::types::{BidRenderer, OrchestratorExt}; +use crate::auction::types::{BidRenderSourceV1, OrchestratorExt}; pub type OpenRtbRequest = trusted_server_openrtb::BidRequest; pub type OpenRtbResponse = trusted_server_openrtb::BidResponse; @@ -180,7 +180,7 @@ impl ToExt for BidExt<'_> {} #[derive(Debug, Serialize)] pub struct BidTrustedServerExt<'a> { - pub renderer: &'a BidRenderer, + pub renderer: &'a BidRenderSourceV1, } #[derive(Debug, Serialize)] diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c86aa0ffb..84b6749a5 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -8363,7 +8363,7 @@ mod tests { MatchedSlotsContext, build_ad_slots_script, build_auction_request, build_bid_map, build_bids_script, html_escape_for_script, }; - use crate::auction::types::{ApsRendererV1, ApsTagType, Bid, BidRenderer, MediaType}; + use crate::auction::types::{ApsRendererV1, ApsTagType, Bid, BidRenderSourceV1, MediaType}; use crate::consent::ConsentContext; use crate::creative_opportunities::{ CreativeOpportunitiesConfig, CreativeOpportunityFormat, CreativeOpportunitySlot, @@ -8613,7 +8613,7 @@ mod tests { 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 { + bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "selected-bid".to_string(), @@ -9461,7 +9461,7 @@ mod tests { bid.bid_id = Some("selected-bid".to_string()); bid.nurl = None; bid.burl = None; - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { + bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "selected-bid".to_string(), diff --git a/crates/trusted-server-js/lib/.prettierignore b/crates/trusted-server-js/lib/.prettierignore index 72274829b..9f9fd6d92 100644 --- a/crates/trusted-server-js/lib/.prettierignore +++ b/crates/trusted-server-js/lib/.prettierignore @@ -1,4 +1,5 @@ node_modules dist coverage - +src/integrations/aps/generated/renderer_validator_v1.ts +test/fixtures/performance/aps-tsjs-prechange.json diff --git a/crates/trusted-server-js/lib/package.json b/crates/trusted-server-js/lib/package.json index e8e732736..770530be2 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -7,6 +7,8 @@ "scripts": { "build": "node build-all.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", "dev": "vite build --watch", "test": "vitest run", "test:watch": "vitest", diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 19dc966bc..d4d0eebe9 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -66,7 +66,24 @@ export interface ApsRendererV1 { height: number; } -export type AuctionBidRenderer = ApsRendererV1; +export interface AdmRenderSourceV1 { + type: 'adm'; + version: 1; + adm: string; + width: number; + height: number; +} + +export interface CacheRenderSourceV1 { + type: 'cache'; + version: 1; + cacheId: string; + fetchUrl: string; + width: number; + height: number; +} + +export type BidRenderSourceV1 = ApsRendererV1 | AdmRenderSourceV1 | CacheRenderSourceV1; /** A client-side Prebid bid's generated ad ID bound to its APS render capability. */ export interface ApsPrebidRendererEntry { @@ -98,7 +115,7 @@ export interface AuctionBidData { nurl?: string | undefined; burl?: string | undefined; /** Typed winning-bid renderer capability. */ - renderer?: AuctionBidRenderer | undefined; + renderer?: BidRenderSourceV1 | undefined; /** Winning creative width used by the inline render bridge. */ w?: number | undefined; /** Winning creative height used by the inline render bridge. */ diff --git a/crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts b/crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts new file mode 100644 index 000000000..33d714185 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts @@ -0,0 +1,141 @@ +// @generated by scripts/generate-aps-renderer-contract.mjs +// schema-sha256: e7ed370e6ccbeb30660b63ae0837dbde6ddd56e81f606a544d37b9d0b99f5d1d +// 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/integrations/aps/render.ts b/crates/trusted-server-js/lib/src/integrations/aps/render.ts index 915b74d3c..caf198f8c 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -1,28 +1,16 @@ import { log } from '../../core/log'; import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types'; +import { + classifyApsRendererDescriptorV1, + classifyApsRendererV1, +} from './generated/renderer_validator_v1'; + export const APS_RENDERER_PATH = '/integrations/aps/renderer'; 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'; @@ -43,89 +31,21 @@ 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 { +function isExactRendererResult(value: unknown): 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]) - ); + return actual.length === 2 && actual[0] === 'message' && actual[1] === 'nonce'; } /** 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 - ) { + if (classifyApsRendererDescriptorV1(value) !== 'accepted') { return undefined; } return value as unknown as ApsRendererV1; } -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; - } - - 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)); - } catch { - return undefined; - } -} - -function validCreativeUrl(value: string, publisherOrigin: string): boolean { - if (new TextEncoder().encode(value).length > MAX_CREATIVE_URL_BYTES) return false; - - try { - const url = new URL(value); - return ( - url.protocol === 'https:' && - url.username === '' && - url.password === '' && - url.origin !== publisherOrigin - ); - } catch { - return false; - } -} - /** Fully validate the exact APS envelope and cross-check every duplicated descriptor field. */ export function validateApsRenderer( value: unknown, @@ -136,43 +56,8 @@ export function validateApsRenderer( if (cached?.publisherOrigin === publisherOrigin) return cached.renderer; } - 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)); - } 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; - } + 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 }); @@ -373,7 +258,7 @@ export function renderApsCreative({ slotId, renderer: input }: RenderApsCreative iframe.style.display = ''; }; function receive(event: MessageEvent): void { - if (event.source !== iframe.contentWindow || !hasExactKeys(event.data, ['message', 'nonce'])) { + if (event.source !== iframe.contentWindow || !isExactRendererResult(event.data)) { return; } if (event.data.nonce !== nonce) return; 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/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..ef605725a --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://iabtechlab.com/trusted-server/aps-renderer-v1.schema.json", + "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/integrations/aps/render.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts index 2ee02cb56..22ef77702 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,8 +1,10 @@ import { afterEach, beforeEach, 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/integrations/aps/generated/renderer_validator_v1'; import { APS_RENDERER_PATH, APS_RENDERER_SANDBOX, @@ -50,7 +52,235 @@ 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('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(); diff --git a/scripts/generate-aps-renderer-contract.mjs b/scripts/generate-aps-renderer-contract.mjs new file mode 100644 index 000000000..53c5e8b7b --- /dev/null +++ b/scripts/generate-aps-renderer-contract.mjs @@ -0,0 +1,315 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const fixtureRoot = path.join( + repositoryRoot, + 'crates/trusted-server-js/lib/test/fixtures' +); +const schemaPath = path.join(fixtureRoot, 'aps-renderer-v1.schema.json'); +const corpusPath = path.join(fixtureRoot, 'aps-renderer-v1-corpus.json'); +const es5Path = path.join( + repositoryRoot, + 'crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js' +); +const typescriptPath = path.join( + repositoryRoot, + 'crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts' +); + +const [schemaText, corpusText] = await Promise.all([ + readFile(schemaPath, 'utf8'), + readFile(corpusPath, 'utf8'), +]); +const schema = JSON.parse(schemaText); +const corpus = JSON.parse(corpusText); + +function invariant(condition, message) { + if (!condition) throw new Error(message); +} + +invariant(schema?.properties?.type?.const === 'aps', 'schema type should be aps'); +invariant(schema?.properties?.version?.const === 1, 'schema version should be 1'); +invariant( + schema?.properties?.width?.minimum === 1 && + schema?.properties?.height?.minimum === 1, + 'schema minimum dimensions should be 1' +); +invariant( + schema?.properties?.width?.maximum === 4096 && + schema?.properties?.height?.maximum === 4096, + 'schema maximum dimensions should be 4096' +); +invariant( + schema?.properties?.aaxResponse?.['x-decodedMaxBytes'] === 262144, + 'schema decoded AAX limit should be 256 KiB' +); +invariant(corpus?.schemaVersion === 1, 'corpus schema version should be 1'); +invariant(Array.isArray(corpus?.vectors) && corpus.vectors.length > 0, 'corpus should have vectors'); +for (const result of [ + 'accepted', + 'descriptor_invalid', + 'invalid_dimensions', + 'dimensions_out_of_range', +]) { + invariant( + corpus.vectors.some((vector) => vector.expected === result), + 'corpus should exercise ' + result + ); +} + +const sha256 = (value) => createHash('sha256').update(value).digest('hex'); +const schemaHash = sha256(schemaText); +const corpusHash = sha256(corpusText); +const generatedHeader = + '// @generated by scripts/generate-aps-renderer-contract.mjs\n' + + '// schema-sha256: ' + + schemaHash + + '\n' + + '// corpus-sha256: ' + + corpusHash + + '\n'; + +const requiredKeys = [...schema.required].sort(); +const optionalKeys = Object.keys(schema.properties) + .filter((key) => !schema.required.includes(key)) + .sort(); +invariant( + optionalKeys.length === 1 && optionalKeys[0] === 'creativeId', + 'creativeId should be the only optional descriptor key' +); + +const constantsSource = + 'var DESCRIPTOR_KEYS = ' + + JSON.stringify(requiredKeys) + + ';\n' + + 'var DESCRIPTOR_KEYS_WITH_CREATIVE_ID = ' + + JSON.stringify([...requiredKeys, 'creativeId'].sort()) + + ';\n' + + 'var ENVELOPE_ROOT_KEYS = ' + + JSON.stringify([...schema['x-envelope'].rootKeys].sort()) + + ';\n' + + 'var ENVELOPE_SEAT_KEYS = ' + + JSON.stringify([...schema['x-envelope'].seatKeys].sort()) + + ';\n' + + 'var ENVELOPE_BID_KEYS = ' + + JSON.stringify([...schema['x-envelope'].bidKeys].sort()) + + ';\n' + + 'var ENVELOPE_EXT_KEYS = ' + + JSON.stringify([...schema['x-envelope'].extKeys].sort()) + + ';\n' + + 'var MAX_ACCOUNT_ID_BYTES = ' + + schema.properties.accountId['x-utf8MaxBytes'] + + ';\n' + + 'var MAX_BID_ID_BYTES = ' + + schema.properties.bidId['x-utf8MaxBytes'] + + ';\n' + + 'var MAX_CREATIVE_ID_BYTES = ' + + schema.properties.creativeId['x-utf8MaxBytes'] + + ';\n' + + 'var MAX_CREATIVE_URL_BYTES = ' + + schema.properties.creativeUrl['x-utf8MaxBytes'] + + ';\n' + + 'var MAX_RENDER_ENVELOPE_BYTES = ' + + schema.properties.aaxResponse['x-decodedMaxBytes'] + + ';\n' + + 'var MAX_RENDER_ENVELOPE_BASE64_BYTES = ' + + 4 * Math.ceil(schema.properties.aaxResponse['x-decodedMaxBytes'] / 3) + + ';\n' + + 'var STANDARD_BASE64_PATTERN = ' + + JSON.stringify(schema.properties.aaxResponse.pattern) + + ';\n'; + +const validatorSource = String.raw` +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_DESCRIPTOR_CLASSIFIER*/ 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_CLASSIFIER*/ 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'; +} +`; + +function stripTypeMarkers(source) { + return source + .replaceAll('/*EXPORT_CLASSIFIER*/ ', '') + .replaceAll('/*EXPORT_DESCRIPTOR_CLASSIFIER*/ ', '') + .replace(/\/\*:[^*]+\*\//g, ''); +} + +function applyTypeMarkers(source) { + return source + .replaceAll('/*EXPORT_CLASSIFIER*/ ', 'export ') + .replaceAll('/*EXPORT_DESCRIPTOR_CLASSIFIER*/ ', 'export ') + .replace(/\/\*:([^*]+)\*\//g, ':$1'); +} + +const es5Output = + generatedHeader + + constantsSource + + 'var RENDER_DIMENSION_MIN = ' + + schema.properties.width.minimum + + ';\n' + + 'var RENDER_DIMENSION_MAX = ' + + schema.properties.width.maximum + + ';\n' + + stripTypeMarkers(validatorSource).trimStart(); + +const typescriptOutput = + generatedHeader + + '/* eslint-disable */\n' + + "export type ApsRendererValidationResult = 'accepted' | 'descriptor_invalid' | " + + "'invalid_dimensions' | 'dimensions_out_of_range';\n" + + constantsSource + + 'export { MAX_ACCOUNT_ID_BYTES, MAX_BID_ID_BYTES, MAX_CREATIVE_ID_BYTES, ' + + 'MAX_RENDER_ENVELOPE_BASE64_BYTES };\n' + + 'export const RENDER_DIMENSION_MIN = ' + + schema.properties.width.minimum + + ';\n' + + 'export const RENDER_DIMENSION_MAX = ' + + schema.properties.width.maximum + + ';\n' + + applyTypeMarkers(validatorSource).trimStart(); + +const outputs = [ + [es5Path, es5Output], + [typescriptPath, typescriptOutput], +]; +const checkOnly = process.argv.slice(2).includes('--check'); + +if (checkOnly) { + const stale = []; + for (const [outputPath, expected] of outputs) { + let actual; + try { + actual = await readFile(outputPath, 'utf8'); + } catch { + stale.push(path.relative(repositoryRoot, outputPath)); + continue; + } + if (actual !== expected) stale.push(path.relative(repositoryRoot, outputPath)); + } + if (stale.length > 0) { + throw new Error('stale APS renderer contract output: ' + stale.join(', ')); + } +} else { + for (const [outputPath, output] of outputs) { + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, output); + } +} From 1b3b649ad0a933cdde6b81af43ef7a0527f53834 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:43:11 -0700 Subject: [PATCH 016/194] Harden APS admission with typed drop reasons --- .../src/auction/formats.rs | 25 +- .../trusted-server-core/src/auction/types.rs | 187 +++++++- .../src/integrations/aps.rs | 442 ++++++++++++++---- crates/trusted-server-core/src/publisher.rs | 29 +- 4 files changed, 574 insertions(+), 109 deletions(-) diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index a55abb3fc..98d1943da 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -9,7 +9,7 @@ use error_stack::{Report, ResultExt, ensure}; use http::{HeaderValue, Request, Response, StatusCode, header}; use serde::Deserialize; use serde_json::Value as JsonValue; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{HashMap, HashSet}; use url::Url; use uuid::Uuid; @@ -29,8 +29,8 @@ use crate::settings::Settings; use super::orchestrator::OrchestrationResult; use super::types::{ - AdFormat, AdSlot, AuctionRequest, DeviceInfo, MediaType, OrchestratorExt, ProviderSummary, - PublisherInfo, SiteInfo, UserInfo, + AdFormat, AdSlot, AuctionDropReason, AuctionDropReasons, AuctionRequest, DeviceInfo, MediaType, + OrchestratorExt, ProviderSummary, PublisherInfo, SiteInfo, UserInfo, record_auction_drop, }; /// Request body for `POST /auction` (tsjs / Prebid.js wire format). @@ -289,16 +289,13 @@ 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, + pub dropped_winner_reasons: AuctionDropReasons, } impl AuctionDeliveryReport { - fn record_drop(&mut self, reason: &str) { + fn record_drop(&mut self, reason: AuctionDropReason) { self.dropped_winner_count += 1; - *self - .dropped_winner_reasons - .entry(reason.to_string()) - .or_default() += 1; + record_auction_drop(&mut self.dropped_winner_reasons, reason); } } @@ -376,7 +373,7 @@ pub(crate) fn convert_to_openrtb_response_with_report( slot_id, bid.bidder ); - delivery.record_drop("multiple_render_sources"); + delivery.record_drop(AuctionDropReason::MultipleRenderSources); continue; } @@ -409,7 +406,7 @@ pub(crate) fn convert_to_openrtb_response_with_report( slot_id, bid.bidder ); - delivery.record_drop("renderer_extension_serialization_failed"); + delivery.record_drop(AuctionDropReason::RendererExtensionSerializationFailed); continue; }; (None, Some(ext)) @@ -420,7 +417,7 @@ pub(crate) fn convert_to_openrtb_response_with_report( slot_id, bid.bidder ); - delivery.record_drop("no_render_source"); + delivery.record_drop(AuctionDropReason::NoRenderSource); continue; }; @@ -1451,7 +1448,7 @@ mod tests { ); assert_eq!(conversion.delivery.dropped_winner_count, 2); assert_eq!( - conversion.delivery.dropped_winner_reasons["no_render_source"], + conversion.delivery.dropped_winner_reasons[&AuctionDropReason::NoRenderSource], 2 ); let json = response_json(conversion.response); @@ -1518,7 +1515,7 @@ mod tests { conversion .delivery .dropped_winner_reasons - .contains_key("multiple_render_sources"), + .contains_key(&AuctionDropReason::MultipleRenderSources), "should report the exact ambiguous-source reason" ); } diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 1287512e7..8400d9927 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -156,6 +156,121 @@ 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. + #[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; +} + /// 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 @@ -646,7 +761,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. @@ -702,6 +817,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)] @@ -709,6 +848,52 @@ 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(), diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 855373db5..ec288d2d7 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -1,6 +1,6 @@ //! Amazon Publisher Services (APS/TAM) `OpenRTB` integration. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; @@ -18,9 +18,9 @@ use validator::{Validate, ValidationError}; use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; use crate::auction::types::{ - AdSlot, ApsRendererV1, ApsRendererValidationResult, ApsTagType, AuctionContext, AuctionRequest, - AuctionResponse, Bid, BidRenderSourceV1, MediaType, RENDER_DIMENSION_MAX, - classify_aps_renderer_v1, + AdSlot, ApsRendererV1, ApsRendererValidationResult, ApsTagType, AuctionContext, + AuctionDropReason, AuctionDropReasons, AuctionRequest, AuctionResponse, Bid, BidRenderSourceV1, + MediaType, RENDER_DIMENSION_MAX, classify_aps_renderer_v1, record_auction_drop, }; use crate::error::TrustedServerError; use crate::integrations::{ @@ -41,6 +41,7 @@ const DEFAULT_CURRENCY: &str = "USD"; const APS_SDK_SOURCE: &str = "prebid"; const APS_SDK_VERSION: &str = "2.2.0"; const MAX_ACCOUNT_ID_BYTES: usize = 1024; +const MAX_BID_ID_BYTES: usize = 64; const MAX_CREATIVE_ID_BYTES: usize = 1024; const MAX_DEBUG_RESPONSE_PREVIEW_BYTES: usize = 512; const MAX_CREATIVE_URL_BYTES: usize = 4096; @@ -658,7 +659,7 @@ impl ApsAuctionProvider { &self, input: ApsRendererInput<'_>, publisher_origin: &str, - ) -> Option { + ) -> Result { let tag_type_value = match input.tag_type { ApsTagType::Iframe => "iframe", ApsTagType::Script => "script", @@ -677,9 +678,10 @@ impl ApsAuctionProvider { }] }] }); - let serialized = serde_json::to_vec(&envelope).ok()?; + let serialized = serde_json::to_vec(&envelope) + .map_err(|_| AuctionDropReason::InvalidProviderResponse)?; if serialized.len() > MAX_RENDER_ENVELOPE_BYTES { - return None; + return Err(AuctionDropReason::RenderPayloadTooLarge); } let renderer = BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, @@ -692,14 +694,14 @@ impl ApsAuctionProvider { width: input.width, height: input.height, }); - let value = serde_json::to_value(&renderer).ok()?; - (classify_aps_renderer_v1(&value, publisher_origin) - == ApsRendererValidationResult::Accepted) - .then_some(renderer) - } - - fn increment_reason(reasons: &mut BTreeMap, reason: &'static str) { - *reasons.entry(reason.to_string()).or_default() += 1; + let value = serde_json::to_value(&renderer) + .map_err(|_| AuctionDropReason::InvalidProviderResponse)?; + if classify_aps_renderer_v1(&value, publisher_origin) + != ApsRendererValidationResult::Accepted + { + return Err(AuctionDropReason::InvalidProviderResponse); + } + Ok(renderer) } fn parse_bid( @@ -707,91 +709,104 @@ impl ApsAuctionProvider { value: &Json, slots: &HashMap<&str, &AdSlot>, publisher_origin: &str, - ) -> Result { - let bid_id = value - .get("id") - .and_then(Json::as_str) - .ok_or("missing_render_source")?; - if bid_id.is_empty() - || bid_id.len() > 64 - || bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) - { - return Err("invalid_bid_id"); + duplicate_ids: &HashSet, + ) -> Result { + let object = value.as_object().ok_or(AuctionDropReason::MalformedBid)?; + let Some(bid_id_value) = object.get("id") else { + return Err(AuctionDropReason::MissingUpstreamBidId); + }; + let bid_id = bid_id_value + .as_str() + .ok_or(AuctionDropReason::InvalidUpstreamBidId)?; + if bid_id.is_empty() { + return Err(AuctionDropReason::MissingUpstreamBidId); + } + if bid_id.len() > MAX_BID_ID_BYTES { + return Err(AuctionDropReason::UpstreamBidIdTooLarge); + } + if bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) { + return Err(AuctionDropReason::InvalidUpstreamBidId); + } + if duplicate_ids.contains(bid_id) { + return Err(AuctionDropReason::DuplicateUpstreamBidId); } let slot_id = value .get("impid") .and_then(Json::as_str) - .ok_or("unknown_impid")?; - let slot = slots.get(slot_id).ok_or("unknown_impid")?; + .ok_or(AuctionDropReason::UnknownImpression)?; + let slot = slots + .get(slot_id) + .ok_or(AuctionDropReason::UnknownImpression)?; let price = value .get("price") .and_then(Json::as_f64) .filter(|price| price.is_finite() && *price >= 0.0) - .ok_or("invalid_price")?; + .ok_or(AuctionDropReason::InvalidPrice)?; if value .get("mtype") .is_some_and(|mtype| mtype.as_i64() != Some(1)) { - return Err("unsupported_media_type"); + return Err(AuctionDropReason::UnsupportedMediaType); } let parse_dimension = |field: &str| { let number = value .get(field) .and_then(Json::as_f64) .filter(|number| number.is_finite() && number.fract() == 0.0 && *number > 0.0) - .ok_or("invalid_dimensions")?; + .ok_or(AuctionDropReason::InvalidDimensions)?; if number > RENDER_DIMENSION_MAX as f64 { - return Err("dimensions_out_of_range"); + return Err(AuctionDropReason::DimensionsOutOfRange); } - u32::try_from(number as u64).map_err(|_| "dimensions_out_of_range") + u32::try_from(number as u64).map_err(|_| AuctionDropReason::DimensionsOutOfRange) }; let width = parse_dimension("w")?; let height = parse_dimension("h")?; if !Self::compatible_dimensions(slot, width, height) { - return Err("invalid_dimensions"); + return Err(AuctionDropReason::InvalidDimensions); } let ext = value .get("ext") .and_then(Json::as_object) - .ok_or("missing_render_source")?; - let creative_url = ext - .get("creativeurl") - .and_then(Json::as_str) - .ok_or("missing_render_source")?; + .ok_or(AuctionDropReason::MissingCreativeUrl)?; + let Some(creative_url_value) = ext.get("creativeurl") else { + return Err(AuctionDropReason::MissingCreativeUrl); + }; + let creative_url = creative_url_value + .as_str() + .ok_or(AuctionDropReason::InvalidCreativeUrl)?; if !self.valid_creative_url(creative_url, publisher_origin) { - return Err("invalid_creative_url"); + return Err(AuctionDropReason::InvalidCreativeUrl); } let tag_type = match ext.get("tagtype").and_then(Json::as_str) { Some("iframe") => ApsTagType::Iframe, Some("script") if self.config.allow_script_creatives => ApsTagType::Script, - Some("script") => return Err("script_rendering_disabled"), - _ => return Err("unsupported_tagtype"), + Some("script") => return Err(AuctionDropReason::ScriptRenderingDisabled), + _ => return Err(AuctionDropReason::InvalidTagType), + }; + let creative_id = match value.get("crid") { + None => None, + Some(Json::String(creative_id)) if creative_id.is_empty() => None, + Some(Json::String(creative_id)) => Some(creative_id.clone()), + Some(_) => return Err(AuctionDropReason::InvalidCreativeId), }; - let creative_id = value - .get("crid") - .and_then(Json::as_str) - .filter(|creative_id| !creative_id.is_empty()) - .map(str::to_string); if creative_id .as_ref() .is_some_and(|creative_id| creative_id.len() > MAX_CREATIVE_ID_BYTES) { - return Err("creative_id_too_large"); + return Err(AuctionDropReason::CreativeIdTooLarge); } - let renderer = self - .build_renderer( - ApsRendererInput { - bid_id, - creative_id: creative_id.clone(), - tag_type, - creative_url, - price, - width, - height, - }, - publisher_origin, - ) - .ok_or("render_payload_too_large")?; + let renderer = self.build_renderer( + ApsRendererInput { + bid_id, + creative_id: creative_id.clone(), + tag_type, + creative_url, + price, + width, + height, + }, + publisher_origin, + )?; let adomain = value .get("adomain") .and_then(Json::as_array) @@ -841,15 +856,15 @@ impl ApsAuctionProvider { .is_some_and(|seatbids| !seatbids.is_array()) { return AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms) - .with_metadata("drop_reasons", json!({"unexpected_response_shape": 1})); + .with_drop_reason(AuctionDropReason::InvalidProviderResponse); } if value .get("cur") .and_then(Json::as_str) .is_some_and(|currency| !currency.eq_ignore_ascii_case(DEFAULT_CURRENCY)) { - return AuctionResponse::no_bid(APS_INTEGRATION_ID, response_time_ms) - .with_metadata("drop_reasons", json!({"unsupported_currency": 1})); + return AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms) + .with_drop_reason(AuctionDropReason::InvalidProviderResponse); } let slots: HashMap<&str, &AdSlot> = request @@ -859,10 +874,31 @@ impl ApsAuctionProvider { .collect(); let seatbids = value.get("seatbid").and_then(Json::as_array); let seatbid_count = seatbids.map_or(0, Vec::len); - let mut reasons = BTreeMap::new(); + let mut reasons = AuctionDropReasons::new(); let mut selected: HashMap = HashMap::new(); let mut dropped = 0_u64; + let mut id_counts = HashMap::<&str, usize>::new(); + for candidate in seatbids + .into_iter() + .flatten() + .filter_map(|seatbid| seatbid.get("bid").and_then(Json::as_array)) + .flatten() + { + if let Some(bid_id) = candidate.get("id").and_then(Json::as_str) + && !bid_id.is_empty() + && bid_id.len() <= MAX_BID_ID_BYTES + && !bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) + { + *id_counts.entry(bid_id).or_default() += 1; + } + } + let duplicate_ids: HashSet = id_counts + .into_iter() + .filter(|(_, count)| *count > 1) + .map(|(bid_id, _)| bid_id.to_string()) + .collect(); + let publisher_origin = request .publisher .page_url @@ -874,11 +910,11 @@ impl ApsAuctionProvider { for seatbid in seatbids.into_iter().flatten() { let Some(bids) = seatbid.get("bid").and_then(Json::as_array) else { dropped += 1; - Self::increment_reason(&mut reasons, "empty_seatbid_bids"); + record_auction_drop(&mut reasons, AuctionDropReason::EmptySeatBidBids); continue; }; for value in bids { - match self.parse_bid(value, &slots, &publisher_origin) { + match self.parse_bid(value, &slots, &publisher_origin, &duplicate_ids) { Ok(candidate) => { let replace = selected.get(&candidate.slot_id).is_none_or(|current| { let candidate_price = candidate.price.unwrap_or_default(); @@ -894,42 +930,45 @@ impl ApsAuctionProvider { .is_some() { dropped += 1; - Self::increment_reason(&mut reasons, "lost_to_higher_bid"); + record_auction_drop( + &mut reasons, + AuctionDropReason::LostToHigherBid, + ); } } else { dropped += 1; - Self::increment_reason(&mut reasons, "lost_to_higher_bid"); + record_auction_drop(&mut reasons, AuctionDropReason::LostToHigherBid); } } Err(reason) => { dropped += 1; - Self::increment_reason(&mut reasons, reason); + record_auction_drop(&mut reasons, reason); } } } } if seatbid_count == 0 { - Self::increment_reason(&mut reasons, "empty_seatbid"); + record_auction_drop(&mut reasons, AuctionDropReason::EmptySeatBid); } let accepted = selected.len(); let metadata = [ ("seatbid_count".to_string(), json!(seatbid_count)), ("accepted_bid_count".to_string(), json!(accepted)), ("dropped_bid_count".to_string(), json!(dropped)), - ("drop_reasons".to_string(), json!(reasons)), ]; let mut response = if selected.is_empty() { AuctionResponse::no_bid(APS_INTEGRATION_ID, response_time_ms) } else { - AuctionResponse::success( - APS_INTEGRATION_ID, - selected.into_values().collect(), - response_time_ms, - ) + let bids = request + .slots + .iter() + .filter_map(|slot| selected.remove(&slot.id)) + .collect(); + AuctionResponse::success(APS_INTEGRATION_ID, bids, response_time_ms) }; response.metadata.extend(metadata); - response + response.with_drop_reasons(&reasons) } async fn parse_response_inner( @@ -1001,7 +1040,7 @@ impl ApsAuctionProvider { Err(error) => { log::warn!("Failed to parse APS response JSON: {error}"); let parsed = AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms) - .with_metadata("drop_reasons", json!({"unexpected_response_shape": 1})); + .with_drop_reason(AuctionDropReason::InvalidProviderResponse); return Ok(self.attach_debug_metadata( parsed, debug_enabled, @@ -1017,7 +1056,7 @@ impl ApsAuctionProvider { "APS cannot parse a successful bid response without the original auction request context" ); let response = AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms) - .with_metadata("drop_reasons", json!({"missing_request_context": 1})); + .with_drop_reason(AuctionDropReason::MissingRequestContext); return Ok(self.attach_debug_metadata( response, debug_enabled, @@ -1267,8 +1306,8 @@ pub fn register_providers( mod tests { use super::*; use crate::auction::types::{ - AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, DeviceInfo, PublisherInfo, - UserInfo, + AdFormat, AdSlot, AuctionContext, AuctionDropReason, AuctionRequest, BidStatus, DeviceInfo, + PublisherInfo, UserInfo, }; use crate::consent::ConsentContext; use crate::openrtb::{Eid, Uid}; @@ -1340,6 +1379,12 @@ mod tests { }) } + fn drop_count(response: &AuctionResponse, reason: AuctionDropReason) -> u64 { + response.metadata["drop_reasons"][reason.as_str()] + .as_u64() + .unwrap_or_default() + } + #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct RendererCorpus { @@ -2200,12 +2245,223 @@ mod tests { let response = provider.parse_aps_response(&value, 12, &request()); assert!(response.bids.is_empty()); assert_eq!( - response.metadata["drop_reasons"]["unexpected_response_shape"], + drop_count(&response, AuctionDropReason::InvalidProviderResponse), 1 ); } } + #[test] + fn upstream_bid_ids_are_required_bounded_control_free_and_response_unique() { + let provider = ApsAuctionProvider::new(config()); + let mut missing = bid("missing", 1.0, "iframe"); + missing + .as_object_mut() + .expect("should build an object bid") + .remove("id"); + let empty = bid("", 1.1, "iframe"); + let oversized = bid(&format!("{}x", "é".repeat(32)), 1.2, "iframe"); + let control = bid("control\u{0000}id", 1.3, "iframe"); + let duplicate_low = bid("duplicate", 1.4, "iframe"); + let duplicate_high = bid("duplicate", 9.0, "iframe"); + let valid_boundary = bid(&"é".repeat(32), 2.0, "iframe"); + + let response = provider.parse_aps_response( + &json!({"seatbid": [{"bid": [ + missing, + empty, + oversized, + control, + duplicate_low, + duplicate_high, + valid_boundary + ]}]}), + 12, + &request(), + ); + + assert_eq!(response.status, BidStatus::Success); + assert_eq!(response.bids.len(), 1); + assert_eq!( + response.bids[0].bid_id.as_deref(), + Some("é".repeat(32).as_str()) + ); + assert_eq!( + drop_count(&response, AuctionDropReason::MissingUpstreamBidId), + 2 + ); + assert_eq!( + drop_count(&response, AuctionDropReason::UpstreamBidIdTooLarge), + 1 + ); + assert_eq!( + drop_count(&response, AuctionDropReason::InvalidUpstreamBidId), + 1 + ); + assert_eq!( + drop_count(&response, AuctionDropReason::DuplicateUpstreamBidId), + 2 + ); + } + + #[test] + fn bid_validation_is_typed_and_isolated_from_a_valid_sibling() { + let provider = ApsAuctionProvider::new(config()); + let mut unknown_imp = bid("unknown-imp", 1.0, "iframe"); + unknown_imp["impid"] = json!("not-requested"); + let negative_price = bid("negative-price", -0.1, "iframe"); + let mut wrong_price = bid("wrong-price", 1.0, "iframe"); + wrong_price["price"] = json!("1.0"); + let mut zero_width = bid("zero-width", 1.0, "iframe"); + zero_width["w"] = json!(0); + let mut over_height = bid("over-height", 1.0, "iframe"); + over_height["h"] = json!(4097); + let mut unmatched_size = bid("unmatched-size", 1.0, "iframe"); + unmatched_size["w"] = json!(728); + unmatched_size["h"] = json!(90); + let mut missing_url = bid("missing-url", 1.0, "iframe"); + missing_url["ext"] + .as_object_mut() + .expect("should build a bid extension") + .remove("creativeurl"); + let mut invalid_url = bid("invalid-url", 1.0, "iframe"); + invalid_url["ext"]["creativeurl"] = json!("http://creative.example/render"); + let invalid_tag = bid("invalid-tag", 1.0, "video"); + let mut invalid_creative_id = bid("invalid-creative-id", 1.0, "iframe"); + invalid_creative_id["crid"] = json!(42); + let mut unsupported_media = bid("unsupported-media", 1.0, "iframe"); + unsupported_media["mtype"] = json!(2); + let valid = bid("valid-sibling", 2.0, "iframe"); + + let response = provider.parse_aps_response( + &json!({"seatbid": [{"bid": [ + "malformed", + unknown_imp, + negative_price, + wrong_price, + zero_width, + over_height, + unmatched_size, + missing_url, + invalid_url, + invalid_tag, + invalid_creative_id, + unsupported_media, + valid + ]}]}), + 12, + &request(), + ); + + assert_eq!(response.bids.len(), 1); + assert_eq!(response.bids[0].bid_id.as_deref(), Some("valid-sibling")); + for reason in [ + AuctionDropReason::MalformedBid, + AuctionDropReason::UnknownImpression, + AuctionDropReason::DimensionsOutOfRange, + AuctionDropReason::MissingCreativeUrl, + AuctionDropReason::InvalidCreativeUrl, + AuctionDropReason::InvalidTagType, + AuctionDropReason::InvalidCreativeId, + AuctionDropReason::UnsupportedMediaType, + ] { + assert_eq!(drop_count(&response, reason), 1, "should report {reason:?}"); + } + assert_eq!(drop_count(&response, AuctionDropReason::InvalidPrice), 2); + assert_eq!( + drop_count(&response, AuctionDropReason::InvalidDimensions), + 2 + ); + } + + #[test] + fn dimensions_accept_exact_requested_membership_at_contract_boundaries() { + let provider = ApsAuctionProvider::new(config()); + let mut auction_request = request(); + auction_request.slots = vec![ + AdSlot { + id: "minimum-slot".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 1, + height: 1, + }], + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }, + AdSlot { + id: "maximum-slot".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 4096, + height: 4096, + }], + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }, + ]; + let mut minimum = bid("minimum", 1.0, "iframe"); + minimum["impid"] = json!("minimum-slot"); + minimum["w"] = json!(1); + minimum["h"] = json!(1); + let mut maximum = bid("maximum", 1.0, "iframe"); + maximum["impid"] = json!("maximum-slot"); + maximum["w"] = json!(4096); + maximum["h"] = json!(4096); + + let response = provider.parse_aps_response( + &json!({"seatbid": [{"bid": [minimum, maximum]}]}), + 12, + &auction_request, + ); + + assert_eq!(response.status, BidStatus::Success); + assert_eq!(response.bids.len(), 2); + assert_eq!(response.bids[0].slot_id, "minimum-slot"); + assert_eq!(response.bids[1].slot_id, "maximum-slot"); + assert_eq!(response.metadata["dropped_bid_count"], 0); + } + + #[test] + fn contextual_currency_and_nonfinite_json_are_invalid_provider_responses() { + let provider = ApsAuctionProvider::new(config()); + for value in [ + json!({"contextual": {"slots": []}, "seatbid": [{"bid": [bid("valid", 1.0, "iframe")]}]}), + json!({"cur": "EUR", "seatbid": [{"bid": [bid("eur", 1.0, "iframe")]}]}), + ] { + let response = provider.parse_aps_response(&value, 12, &request()); + assert_eq!(response.status, BidStatus::Error); + assert_eq!( + drop_count(&response, AuctionDropReason::InvalidProviderResponse), + 1 + ); + } + + let body = br#"{"seatbid":[{"bid":[{"id":"overflow","impid":"fictional-slot","price":1e400,"w":300,"h":250,"ext":{"creativeurl":"https://creative.example/render","tagtype":"iframe"}}]}]}"#; + let response = futures::executor::block_on( + provider.parse_response_inner( + PlatformResponse::new( + edgezero_core::http::response_builder() + .status(StatusCode::OK) + .body(EdgeBody::from(body.to_vec())) + .expect("should build nonfinite APS response"), + ), + 12, + Some(&request()), + None, + false, + ), + ) + .expect("should reject nonfinite APS JSON safely"); + assert_eq!(response.status, BidStatus::Error); + assert_eq!( + drop_count(&response, AuctionDropReason::InvalidProviderResponse), + 1 + ); + } + #[test] fn debug_metadata_matches_pbs_httpcalls_shape() { let mut provider_config = config(); @@ -2393,7 +2649,7 @@ mod tests { let oversized = parse_with_context(&provider, oversized); assert_eq!( - malformed.metadata["drop_reasons"]["unexpected_response_shape"], + drop_count(&malformed, AuctionDropReason::InvalidProviderResponse), 1 ); assert_eq!( @@ -2473,7 +2729,7 @@ mod tests { assert!(response.bids.is_empty()); assert_eq!( - response.metadata["drop_reasons"]["unexpected_response_shape"], + drop_count(&response, AuctionDropReason::InvalidProviderResponse), 1 ); } @@ -2553,15 +2809,18 @@ mod tests { } #[test] - fn unsupported_currency_is_a_no_bid() { + fn unsupported_currency_is_an_invalid_provider_response() { let provider = ApsAuctionProvider::new(config()); let response = provider.parse_aps_response( &json!({"cur": "EUR", "seatbid": [{"bid": [bid("eur-bid", 1.0, "iframe")]}]}), 12, &request(), ); - assert_eq!(response.status, BidStatus::NoBid); - assert_eq!(response.metadata["drop_reasons"]["unsupported_currency"], 1); + assert_eq!(response.status, BidStatus::Error); + assert_eq!( + drop_count(&response, AuctionDropReason::InvalidProviderResponse), + 1 + ); } #[test] @@ -2574,10 +2833,7 @@ mod tests { ); assert_eq!(response.bids.len(), 1); assert_eq!(response.bids[0].bid_id.as_deref(), Some("valid")); - assert_eq!( - response.metadata["drop_reasons"]["missing_render_source"], - 1 - ); + assert_eq!(drop_count(&response, AuctionDropReason::MalformedBid), 1); } #[test] @@ -2653,7 +2909,7 @@ mod tests { ); assert!(response.bids.is_empty()); assert_eq!( - response.metadata["drop_reasons"]["missing_render_source"], + drop_count(&response, AuctionDropReason::MissingCreativeUrl), 1 ); assert_eq!(response.metadata["drop_reasons"]["invalid_dimensions"], 1); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 84b6749a5..c8be52201 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1863,6 +1863,7 @@ const MAX_AUCTION_DEBUG_DUMP_BYTES: usize = 256 * 1024; /// `[integration.prebid].debug` is also enabled. Full debug detail remains /// available server-side via `log::trace!`. const DEBUG_DUMP_METADATA_ALLOWLIST: &[&str] = &[ + "drop_reasons", "error_type", "status", "message", @@ -4164,8 +4165,8 @@ mod tests { use super::*; use crate::auction::orchestrator::OrchestrationResult; - use crate::auction::types::AuctionResponse; use crate::auction::types::{AdFormat, AdSlot, MediaType}; + use crate::auction::types::{AuctionDropReason, AuctionResponse}; use crate::integrations::IntegrationRegistry; use crate::platform::test_support::{ StubHttpClient, build_services_with_http_client, noop_services, @@ -4244,6 +4245,32 @@ mod tests { ); } + #[test] + 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(), + 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"); + assert!( + comment.contains("\"drop_reasons\":{\"duplicate_upstream_bid_id\":1}"), + "typed fixed reason/count metadata should remain visible: {comment}" + ); + } + #[test] fn auction_debug_comment_never_leaks_provider_debug_metadata() { // A provider response whose `debug` metadata mirrors the shape prebid From 1814b2a127a473c5b6d67bfcf1cae441bf4babc3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:35:43 -0700 Subject: [PATCH 017/194] Make auction outcomes explicit and provenance-safe --- .../src/auction/endpoints.rs | 6 +- .../src/auction/formats.rs | 719 +++++++- .../src/auction/orchestrator.rs | 1519 +++++++++++++---- .../src/auction/provider.rs | 26 +- .../src/auction/telemetry.rs | 30 +- .../trusted-server-core/src/auction/types.rs | 245 +++ .../src/integrations/adserver_mock.rs | 297 ++-- .../src/integrations/aps.rs | 3 + .../src/integrations/prebid.rs | 8 + crates/trusted-server-core/src/publisher.rs | 41 +- .../trusted-server-js/lib/src/core/auction.ts | 526 +++++- .../trusted-server-js/lib/src/core/types.ts | 41 + .../lib/test/core/auction.test.ts | 502 +++++- crates/trusted-server-js/lib/vitest.config.ts | 6 +- 14 files changed, 3520 insertions(+), 449 deletions(-) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 12f65caab..bab6fe9bd 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -32,7 +32,7 @@ use super::telemetry::{ AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events, emit_auction_events_best_effort_lazy, }; -use super::types::AuctionContext; +use super::types::{AuctionContext, AuctionDecisionSetV1, AuctionSlotFailureReason}; const MAX_CLIENT_EID_SOURCES: usize = 64; const MAX_CLIENT_UIDS_PER_SOURCE: usize = 32; @@ -216,6 +216,10 @@ 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(), }; diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 98d1943da..ca8693f7d 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -7,9 +7,9 @@ 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::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use url::Url; use uuid::Uuid; @@ -29,8 +29,12 @@ use crate::settings::Settings; use super::orchestrator::OrchestrationResult; use super::types::{ - AdFormat, AdSlot, AuctionDropReason, AuctionDropReasons, AuctionRequest, DeviceInfo, MediaType, - OrchestratorExt, ProviderSummary, PublisherInfo, SiteInfo, UserInfo, record_auction_drop, + AdFormat, AdSlot, AuctionDecisionSetV1, AuctionDropReason, AuctionDropReasons, AuctionRequest, + AuctionSlotFailureReason, BidRenderSourceV1, BrowserAuctionBidV1, BrowserAuctionProjectionV1, + 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). @@ -307,6 +311,429 @@ pub(crate) struct OpenRtbResponseConversion { pub delivery: AuctionDeliveryReport, } +#[allow( + dead_code, + reason = "pure coordinated-cutover contract is exercised directly until Task 19 wires endpoints" +)] +pub(crate) mod coordinated_cutover_v1 { + use super::*; + + /// Validated projection plus its exact canonical UTF-8 representation. + #[derive(Debug, Clone)] + pub(crate) struct CanonicalBrowserAuctionProjectionV1 { + /// Deep-owned, validated projection in canonical result/bid/targeting order. + pub projection: BrowserAuctionProjectionV1, + /// Whitespace-free JSON using schema field order. + pub json: Vec, + /// Whether the exact aggregate overflow rule replaced every winner. + pub reduced_for_size: bool, + } + + fn projection_contract_error(message: impl Into) -> Report { + Report::new(TrustedServerError::Auction { + message: message.into(), + }) + } + + fn is_base64url_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') + } + + fn valid_auction_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-') + }) + } + + fn valid_candidate_id(value: &str) -> bool { + value.len() == 12 && value.bytes().all(is_base64url_byte) + } + + fn valid_renderer_reservation_id(value: &str) -> bool { + value + .strip_prefix("r1_") + .is_some_and(|token| token.len() == 22 && token.bytes().all(is_base64url_byte)) + } + + fn valid_provider_name(value: &str) -> bool { + let bytes = value.as_bytes(); + (1..=64).contains(&bytes.len()) + && bytes[0].is_ascii_alphanumeric() + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b'-')) + } + + fn valid_bounded_text(value: &str, maximum_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= maximum_bytes + && !value + .chars() + .any(|character| matches!(character, '\0'..='\u{1f}' | '\u{7f}')) + } + + fn valid_targeting_key(value: &str) -> bool { + !value.is_empty() + && value.len() <= 20 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + } + + fn valid_targeting(targeting: &BTreeMap) -> bool { + targeting.len() <= MAX_BROWSER_AUCTION_TARGETING_ENTRIES + && targeting.iter().all(|(key, value)| { + key != "hb_adid" + && valid_targeting_key(key) + && valid_bounded_text(value, 160) + && value.chars().count() <= 40 + }) + } + + fn valid_render_dimension(value: u32) -> bool { + (RENDER_DIMENSION_MIN..=RENDER_DIMENSION_MAX).contains(&u64::from(value)) + } + + fn 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 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.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, + }, + 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, + }) + } + + #[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, serialize_trusted_server_auction_response_v1, +}; + /// Convert `OrchestrationResult` to `OpenRTB` response format. /// /// Creative HTML in the `adm` field is optionally sanitized and optionally @@ -520,7 +947,8 @@ pub(crate) fn convert_to_openrtb_response_with_report( mod tests { use super::*; use crate::auction::types::{ - ApsRendererV1, ApsTagType, AuctionResponse, Bid, BidRenderSourceV1, BidStatus, + ApsRendererV1, ApsTagType, AuctionDecisionSetV1, AuctionResponse, Bid, BidRenderSourceV1, + BidStatus, }; use crate::openrtb::{Eid, Uid}; use crate::platform::test_support::noop_services; @@ -576,6 +1004,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(), } @@ -584,6 +1017,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()), @@ -624,6 +1060,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(), } @@ -1386,6 +1827,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(), }; @@ -1434,6 +1880,11 @@ 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(), }; @@ -1596,6 +2047,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(), }; @@ -1637,6 +2093,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(), }; @@ -1769,10 +2230,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") @@ -1945,4 +2412,246 @@ 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, + }, + 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 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, + }], + }, + 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 aeda9f7a6..9c567b168 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -1,8 +1,10 @@ //! Auction orchestrator for managing multi-provider auctions. +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::Request; +use rand::{RngCore as _, rngs::OsRng}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; @@ -12,9 +14,38 @@ 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, AuctionRequest, AuctionResponse, + AuctionSlotFailureReason, Bid, BidStatus, SlotAuctionDecisionV1, +}; + +const CANDIDATE_ID_BYTES: usize = 9; +const CANDIDATE_ID_COLLISION_RETRIES: usize = 8; +const MAX_UPSTREAM_BID_ID_BYTES: usize = 64; + +/// Injectable CSPRNG boundary for response-local auction 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<(), ()>; +} + +struct SystemAuctionIdentityGenerator; + +impl AuctionIdentityGenerator for SystemAuctionIdentityGenerator { + fn fill(&self, destination: &mut [u8]) -> Result<(), ()> { + OsRng.try_fill_bytes(destination).map_err(|_| ()) + } +} + +struct NormalizedProviderResponses { + outcomes: Vec, + candidates: HashMap, +} /// In-flight auction requests dispatched to SSP backends. /// @@ -169,6 +200,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 +240,7 @@ fn snapshot_context_request(request: &Request) -> Request { pub struct AuctionOrchestrator { config: AuctionConfig, providers: HashMap>, + identity_generator: Arc, } impl AuctionOrchestrator { @@ -201,6 +250,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, } } @@ -272,6 +334,350 @@ 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 { + for _ in 0..=CANDIDATE_ID_COLLISION_RETRIES { + let mut bytes = [0_u8; CANDIDATE_ID_BYTES]; + if self.identity_generator.fill(&mut bytes).is_err() { + return None; + } + let candidate_id = URL_SAFE_NO_PAD.encode(bytes); + debug_assert_eq!(candidate_id.len(), 12); + if issued.insert(candidate_id.clone()) { + return Some(candidate_id); + } + } + None + } + + 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 +695,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 +745,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 +875,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 +904,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 +913,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,7 +936,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) { @@ -543,6 +967,7 @@ impl AuctionOrchestrator { if effective_timeout == 0 { log::warn!("Auction timeout exhausted before launching provider request; skipping"); + responses.push(provider_timeout_response(provider.provider_name(), 0)); continue; } @@ -639,12 +1064,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 +1089,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 +1162,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 +1273,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 +1353,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 @@ -1102,7 +1518,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 +1659,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 +1738,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 +1768,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 +1867,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 +1906,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, BidRenderSourceV1, 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 +1927,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 @@ -1753,6 +2143,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 +2168,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 +2236,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 +2342,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 +2378,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 +2428,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 +2483,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 +2573,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 +3040,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 +3081,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 +3252,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 +3278,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 +3351,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, @@ -2491,11 +3391,19 @@ mod tests { 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, + }, + ] ); }); } @@ -2730,8 +3638,8 @@ 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))); @@ -2768,10 +3676,22 @@ mod tests { services, }; - 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, + }, + ] ); }); } @@ -3439,11 +4359,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 +4492,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 +4539,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 +4581,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 8400d9927..b2d01a159 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -271,6 +271,206 @@ pub(crate) fn record_auction_drop(reasons: &mut AuctionDropReasons, reason: Auct *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)] +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)] +#[serde(rename_all = "camelCase")] +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)] +#[serde(rename_all = "camelCase")] +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, +} + +/// Complete browser-facing version-1 auction projection. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct BrowserAuctionProjectionV1 { + /// Contract version. + pub version: u8, + /// Ordered decision set for every requested slot. + pub auction: AuctionDecisionSetV1, + /// 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 @@ -634,6 +834,15 @@ pub fn classify_aps_renderer_v1( 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") @@ -897,6 +1106,9 @@ mod tests { 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, @@ -1029,6 +1241,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, @@ -1131,10 +1346,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/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index d50c6cde4..73a460525 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -97,36 +97,29 @@ impl IntegrationConfig for AdServerMockConfig { /// mediation response to restore render/accounting fields that the mock /// mediator endpoint does not echo back. /// -/// Keyed by `(provider_name, slot_id, bidder_name)`. -type BidIndex = HashMap<(String, String, String), Bid>; +/// Keyed only by the server-minted opaque candidate identifier. +type BidIndex = HashMap; /// Builds the SSP-bid lookup index from the orchestrator-provided /// bidder responses on the auction context. -fn build_bid_index(bidder_responses: &[AuctionResponse]) -> BidIndex { +fn build_bid_index(bidder_responses: &[AuctionResponse]) -> Option { let mut index = BidIndex::new(); for response in bidder_responses { for bid in &response.bids { - let key = ( - response.provider.clone(), - bid.slot_id.clone(), - bid.bidder.clone(), - ); - // OpenRTB permits a seat to return multiple bids per imp. This index - // is last-write-wins, so a collision means an earlier bid's - // nurl/burl/cache_* are dropped and win/billing-URL restoration can - // be mis-attributed during mediation. Low severity for the mock - // mediator, but log it so the collision is visible. - if index.insert(key, bid.clone()).is_some() { + let Some(candidate_id) = bid.candidate_id.as_ref() else { log::warn!( - "adserver_mock: duplicate bid for (provider '{}', slot '{}', bidder '{}'); keeping the last — win/billing URL restoration may be mis-attributed", - response.provider, - bid.slot_id, - bid.bidder + "adserver_mock: source bid from provider '{}' lacks a candidate id", + response.provider ); + return None; + }; + if index.insert(candidate_id.clone(), bid.clone()).is_some() { + log::warn!("adserver_mock: duplicate source candidate id"); + return None; } } } - index + Some(index) } /// Mock ad server mediator provider. @@ -179,14 +172,26 @@ impl AdServerMockProvider { ); return None; }; + let Some(candidate_id) = bid.candidate_id.as_deref() else { + log::warn!( + "adserver_mock: omitting source bid for slot '{}' without a candidate id", + bid.slot_id + ); + return None; + }; Some(json!({ "imp_id": bid.slot_id, "price": price, "adm": bid.creative, "w": bid.width, "h": bid.height, - "crid": format!("{}-creative", bid.bidder), + "crid": bid.creative_id, "adomain": bid.adomain, + "ext": { + "trusted_server": { + "candidate_id": candidate_id + } + } })) }) .collect(); @@ -254,80 +259,62 @@ impl AdServerMockProvider { /// Mediation returns decoded prices for all selected bids. /// /// `bid_index` is the SSP-bid lookup built from the auction context's - /// bidder responses. The mock mediator does not echo render/accounting - /// fields back, so they are restored from the index using - /// `(seat, impid, bidder)` where bidder is recovered from the echoed `crid` - /// field (`"{bidder}-creative"` format set during request construction). + /// bidder responses. The mediator may select only by echoing one exact + /// server-minted candidate id; all render and accounting authority is + /// restored from the indexed source candidate. fn parse_mediation_response( &self, json: &Json, response_time_ms: u64, bid_index: &BidIndex, ) -> AuctionResponse { - let empty_array = vec![]; - let seatbid = json["seatbid"].as_array().unwrap_or(&empty_array); - + let Some(seatbids) = json.get("seatbid").and_then(Json::as_array) else { + return AuctionResponse::error("adserver_mock", response_time_ms) + .with_metadata("mediation_error", json!("invalid_candidate_provenance")); + }; let mut all_bids = Vec::new(); - - for seat in seatbid { - let seat_name = seat["seat"].as_str().unwrap_or("unknown"); - let empty_bids = vec![]; - let bids = seat["bid"].as_array().unwrap_or(&empty_bids); - + let mut seen = std::collections::HashSet::new(); + let mut seen_slots = std::collections::HashSet::new(); + for seat in seatbids { + let Some(bids) = seat.get("bid").and_then(Json::as_array) else { + return AuctionResponse::error("adserver_mock", response_time_ms) + .with_metadata("mediation_error", json!("invalid_candidate_provenance")); + }; for bid in bids { - let slot_id = bid["impid"].as_str().unwrap_or("").to_string(); - - // Recover bidder name from crid ("{bidder}-creative") to look up the - // original SSP bid and restore render/accounting fields the mediator drops. - let crid = bid["crid"].as_str().unwrap_or(""); - let bidder = crid.strip_suffix("-creative").unwrap_or_else(|| { - log::debug!( - "adserver_mock: crid '{crid}' does not match '-creative'; render/accounting fields may be missing" - ); - "" - }); - let key = (seat_name.to_string(), slot_id.clone(), bidder.to_string()); - let original = bid_index.get(&key); - let restored_bidder = - original.map_or_else(|| seat_name.to_string(), |b| b.bidder.clone()); - - let width = bid["w"].as_u64().unwrap_or(0) as u32; - let height = bid["h"].as_u64().unwrap_or(0) as u32; - if width == 0 || height == 0 { - log::debug!( - "adserver_mock: bid for slot '{slot_id}' has zero dimension ({width}×{height}), skipping" - ); - continue; + let trusted = bid + .get("ext") + .and_then(Json::as_object) + .and_then(|ext| ext.get("trusted_server")) + .and_then(Json::as_object); + let candidate_id = trusted + .filter(|trusted| trusted.len() == 1) + .and_then(|trusted| trusted.get("candidate_id")) + .and_then(Json::as_str); + let Some(candidate_id) = candidate_id else { + return AuctionResponse::error("adserver_mock", response_time_ms) + .with_metadata("mediation_error", json!("invalid_candidate_provenance")); + }; + let Some(original) = bid_index.get(candidate_id) else { + return AuctionResponse::error("adserver_mock", response_time_ms) + .with_metadata("mediation_error", json!("invalid_candidate_provenance")); + }; + if !seen.insert(candidate_id) + || !seen_slots.insert(original.slot_id.as_str()) + || bid.get("impid").and_then(Json::as_str) != Some(original.slot_id.as_str()) + || bid + .get("price") + .and_then(Json::as_f64) + .is_none_or(|price| !price.is_finite() || price < 0.0) + || (original.renderer.is_some() + && bid.get("adm").and_then(Json::as_str).is_some()) + { + return AuctionResponse::error("adserver_mock", response_time_ms) + .with_metadata("mediation_error", json!("invalid_candidate_provenance")); } - all_bids.push(Bid { - slot_id, - price: bid["price"].as_f64(), - currency: "USD".to_string(), - creative: if original.is_some_and(|bid| bid.renderer.is_some()) { - None - } else { - bid["adm"].as_str().map(String::from) - }, - width, - height, - bidder: restored_bidder, - adomain: bid["adomain"].as_array().map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }), - nurl: original.and_then(|b| b.nurl.clone()), - burl: original.and_then(|b| b.burl.clone()), - bid_id: original.and_then(|bid| bid.bid_id.clone()), - ad_id: original.and_then(|bid| bid.ad_id.clone()), - creative_id: original.and_then(|bid| bid.creative_id.clone()), - renderer: original.and_then(|bid| bid.renderer.clone()), - cache_id: original.and_then(|b| b.cache_id.clone()), - cache_host: original.and_then(|b| b.cache_host.clone()), - cache_path: original.and_then(|b| b.cache_path.clone()), - metadata: HashMap::new(), - }); + let mut resolved = original.clone(); + resolved.price = bid.get("price").and_then(Json::as_f64); + all_bids.push(resolved); } } @@ -507,7 +494,10 @@ impl AuctionProvider for AdServerMockProvider { // Rebuild the SSP-bid lookup from the orchestrator-provided bidder // responses so nurl/burl/ad_id survive mediation. Request-scoped data // travels on the context instead of provider-instance state. - let bid_index = build_bid_index(context.provider_responses.unwrap_or(&[])); + let Some(bid_index) = build_bid_index(context.provider_responses.unwrap_or(&[])) else { + return Ok(AuctionResponse::error("adserver_mock", response_time_ms) + .with_metadata("mediation_error", json!("invalid_candidate_provenance"))); + }; self.parse_response_inner(response, response_time_ms, &bid_index) .await } @@ -621,6 +611,12 @@ mod tests { fn aps_bid(bid_id: &str, price: f64) -> Bid { Bid { slot_id: "header-banner".to_string(), + candidate_id: Some(format!( + "{:012x}", + bid_id.bytes().map(u64::from).sum::() + )), + candidate_provider: Some("aps".to_string()), + renderer_reservation_id: None, price: Some(price), currency: "USD".to_string(), creative: None, @@ -670,6 +666,9 @@ mod tests { status: BidStatus::Success, bids: vec![Bid { slot_id: "header-banner".to_string(), + candidate_id: Some("AAAAAAAAAAAA".to_string()), + candidate_provider: Some("aps".to_string()), + renderer_reservation_id: None, price: Some(3.00), currency: "USD".to_string(), creative: Some("
APS Ad
".to_string()), @@ -696,6 +695,9 @@ mod tests { status: BidStatus::Success, bids: vec![Bid { slot_id: "header-banner".to_string(), + candidate_id: Some("BBBBBBBBBBBB".to_string()), + candidate_provider: Some("test-bidder".to_string()), + renderer_reservation_id: None, price: Some(3.50), currency: "USD".to_string(), creative: Some("
Test Ad
".to_string()), @@ -740,12 +742,26 @@ mod tests { 2 ); assert_eq!(mediation_req["ext"]["config"]["price_floor"], 1.00); + assert_eq!( + mediation_req["ext"]["bidder_responses"][0]["bids"][0]["ext"]["trusted_server"]["candidate_id"], + "AAAAAAAAAAAA" + ); + assert_eq!( + mediation_req["ext"]["bidder_responses"][1]["bids"][0]["ext"]["trusted_server"]["candidate_id"], + "BBBBBBBBBBBB" + ); } #[test] fn test_parse_mediation_response() { let config = AdServerMockConfig::default(); let provider = AdServerMockProvider::new(config); + let source = aps_bid("selected", 1.0); + let candidate_id = source + .candidate_id + .clone() + .expect("source should have candidate id"); + let bid_index = HashMap::from([(candidate_id.clone(), source)]); let mediation_response = json!({ "id": "test-auction-123", @@ -757,11 +773,7 @@ mod tests { "id": "bid-001", "impid": "header-banner", "price": 3.50, - "adm": "
Test Ad
", - "w": 728, - "h": 90, - "crid": "test-creative", - "adomain": ["test.com"] + "ext": {"trusted_server": {"candidate_id": candidate_id}} } ] } @@ -770,7 +782,7 @@ mod tests { }); let auction_response = - provider.parse_mediation_response(&mediation_response, 200, &BidIndex::new()); + provider.parse_mediation_response(&mediation_response, 200, &bid_index); assert_eq!(auction_response.provider, "adserver_mock"); assert_eq!(auction_response.status, BidStatus::Success); @@ -780,7 +792,7 @@ mod tests { let bid = &auction_response.bids[0]; assert_eq!(bid.slot_id, "header-banner"); assert_eq!(bid.price, Some(3.50)); // Mediation returns decoded price - assert_eq!(bid.bidder, "test-bidder"); + assert_eq!(bid.bidder, "aps"); assert_eq!(bid.width, 728); assert_eq!(bid.height, 90); } @@ -788,6 +800,7 @@ mod tests { #[test] fn parse_mediation_response_restores_original_bid_render_fields() { let provider = AdServerMockProvider::new(AdServerMockConfig::default()); + let candidate_id = "AAAAAAAAAAAA"; let mediation_response = json!({ "id": "test-auction-123", "seatbid": [ @@ -798,11 +811,7 @@ mod tests { "id": "mediated-bid-001", "impid": "header-banner", "price": 0.20, - "adm": "
Mediated Ad
", - "w": 728, - "h": 90, - "crid": "mocktioneer-creative", - "adomain": ["example.com"] + "ext": {"trusted_server": {"candidate_id": candidate_id}} } ] } @@ -811,13 +820,12 @@ mod tests { }); let mut bid_index = BidIndex::new(); bid_index.insert( - ( - "prebid".to_string(), - "header-banner".to_string(), - "mocktioneer".to_string(), - ), + candidate_id.to_string(), Bid { slot_id: "header-banner".to_string(), + candidate_id: Some(candidate_id.to_string()), + candidate_provider: Some("prebid".to_string()), + renderer_reservation_id: None, price: Some(0.20), currency: "USD".to_string(), creative: Some("
Original Ad
".to_string()), @@ -894,45 +902,37 @@ mod tests { } #[test] - fn reduced_aps_bid_avoids_mediation_index_renderer_collision() { + fn candidate_index_preserves_multiple_same_provider_slot_bids() { let provider = AdServerMockProvider::new(AdServerMockConfig::default()); - - // Document why APS must reduce before mediation: the mediator index is - // intentionally last-write-wins for identical provider/slot/bidder keys. - let unreduced = AuctionResponse::success( + let response = AuctionResponse::success( "aps", - vec![aps_bid("selected", 2.0), aps_bid("losing-last", 1.0)], + vec![aps_bid("selected", 2.0), aps_bid("losing", 1.0)], 1, ); - let collision_index = build_bid_index(&[unreduced]); - let key = ( - "aps".to_string(), - "header-banner".to_string(), - "aps".to_string(), - ); - assert_eq!( - collision_index - .get(&key) - .and_then(|bid| bid.bid_id.as_deref()), - Some("losing-last"), - "an unreduced response would restore the last candidate's renderer" - ); - - let reduced = AuctionResponse::success("aps", vec![aps_bid("selected", 2.0)], 1); + let index = build_bid_index(std::slice::from_ref(&response)) + .expect("unique candidates should build an exact index"); + assert_eq!(index.len(), 2); + let selected_candidate_id = response.bids[0] + .candidate_id + .clone() + .expect("selected bid should have candidate id"); let mediation_request = provider .build_mediation_request( &create_test_auction_request(), - std::slice::from_ref(&reduced), + std::slice::from_ref(&response), ) - .expect("should build mediation request from reduced APS response"); + .expect("should build mediation request with candidate provenance"); assert_eq!( mediation_request["ext"]["bidder_responses"][0]["bids"] .as_array() .map(Vec::len), - Some(1) + Some(2) + ); + assert_eq!( + mediation_request["ext"]["bidder_responses"][0]["bids"][0]["ext"]["trusted_server"]["candidate_id"], + selected_candidate_id ); - let reduced_index = build_bid_index(&[reduced]); let mediated = provider.parse_mediation_response( &json!({ "seatbid": [{ @@ -940,14 +940,12 @@ mod tests { "bid": [{ "impid": "header-banner", "price": 2.0, - "w": 728, - "h": 90, - "crid": "aps-creative" + "ext": {"trusted_server": {"candidate_id": selected_candidate_id}} }] }] }), 2, - &reduced_index, + &index, ); let winner = mediated .bids @@ -985,6 +983,19 @@ mod tests { assert_eq!(auction_response.bids.len(), 0); } + #[test] + fn missing_seatbid_is_mediation_failure_not_no_bid() { + let provider = AdServerMockProvider::new(AdServerMockConfig::default()); + + let response = provider.parse_mediation_response(&json!({}), 100, &BidIndex::new()); + + assert_eq!(response.status, BidStatus::Error); + assert_eq!( + response.metadata["mediation_error"], + "invalid_candidate_provenance" + ); + } + #[test] fn test_mediation_request_handles_decoded_bid_without_creative() { // Typed-renderer bids retain their decoded price when sent to mediation. @@ -1023,6 +1034,9 @@ mod tests { status: BidStatus::Success, bids: vec![Bid { slot_id: "slot-1".to_string(), + candidate_id: Some("CCCCCCCCCCCC".to_string()), + candidate_provider: Some("aps".to_string()), + renderer_reservation_id: None, price: Some(1.75), currency: "USD".to_string(), creative: None, @@ -1057,6 +1071,7 @@ mod tests { let bid = &bidder_resp["bids"][0]; assert_eq!(bid["imp_id"], "slot-1"); + assert_eq!(bid["ext"]["trusted_server"]["candidate_id"], "CCCCCCCCCCCC"); assert_eq!( bid["price"].as_f64(), @@ -1137,8 +1152,7 @@ mod tests { } #[test] - fn test_parse_mediation_response_with_missing_prices() { - // A malformed mediator response can still omit a selected bid price. + fn malformed_mediator_selection_fails_closed() { let config = AdServerMockConfig::default(); let provider = AdServerMockProvider::new(config); @@ -1173,20 +1187,11 @@ mod tests { let auction_response = provider.parse_mediation_response(&mediation_response, 200, &BidIndex::new()); - assert_eq!(auction_response.status, BidStatus::Success); - assert_eq!(auction_response.bids.len(), 2); - - // First bid should have decoded price - let bid1 = &auction_response.bids[0]; - assert_eq!(bid1.slot_id, "header-banner"); - assert_eq!(bid1.price, Some(3.50)); - - // Second bid should have None price (failed decode) - let bid2 = &auction_response.bids[1]; - assert_eq!(bid2.slot_id, "sidebar"); + assert_eq!(auction_response.status, BidStatus::Error); + assert!(auction_response.bids.is_empty()); assert_eq!( - bid2.price, None, - "Bid without price field should have None price" + auction_response.metadata["mediation_error"], + "invalid_candidate_provenance" ); } diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index ec288d2d7..4cf2b7332 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -820,6 +820,9 @@ impl ApsAuctionProvider { Ok(Bid { slot_id: slot_id.to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(price), currency: DEFAULT_CURRENCY.to_string(), creative: None, diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 5c97a06d7..6a24bffb9 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2432,6 +2432,9 @@ impl PrebidAuctionProvider { Ok(AuctionBid { slot_id, + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(price), // Prebid provides decoded prices currency: DEFAULT_CURRENCY.to_string(), creative, @@ -5759,6 +5762,11 @@ external_bundle_sri = "sha384-AAAA" provider_responses: vec![provider_response], mediator_response: None, winning_bids: HashMap::new(), + decision_set: crate::auction::types::AuctionDecisionSetV1 { + version: 1, + auction_id: "test-auction".to_string(), + results: Vec::new(), + }, total_time_ms: 42, metadata: HashMap::new(), }; diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c8be52201..3562bfabf 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -4166,7 +4166,7 @@ mod tests { use super::*; use crate::auction::orchestrator::OrchestrationResult; use crate::auction::types::{AdFormat, AdSlot, MediaType}; - use crate::auction::types::{AuctionDropReason, AuctionResponse}; + use crate::auction::types::{AuctionDecisionSetV1, AuctionDropReason, AuctionResponse}; use crate::integrations::IntegrationRegistry; use crate::platform::test_support::{ StubHttpClient, build_services_with_http_client, noop_services, @@ -4180,6 +4180,9 @@ mod tests { fn make_test_bid_with_creative(creative: &str) -> Bid { Bid { slot_id: "slot".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(1.0), currency: "USD".to_string(), creative: Some(creative.to_string()), @@ -4212,6 +4215,11 @@ mod tests { ], 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(), }; @@ -4253,6 +4261,11 @@ mod tests { 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(), }; @@ -4299,6 +4312,11 @@ mod tests { 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(), }; @@ -8448,6 +8466,9 @@ mod tests { ) -> Bid { Bid { slot_id: slot_id.to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(price), currency: "USD".to_string(), creative: None, @@ -8975,6 +8996,9 @@ mod tests { fn cached_bid_with_creative(creative: &str) -> Bid { Bid { slot_id: "atf_sidebar_ad".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(1.50), currency: "USD".to_string(), creative: Some(creative.to_string()), @@ -9377,6 +9401,9 @@ mod tests { "atf_sidebar_ad".to_string(), Bid { slot_id: "atf_sidebar_ad".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(1.50), currency: "USD".to_string(), creative: None, @@ -9434,6 +9461,9 @@ mod tests { "atf_sidebar_ad".to_string(), Bid { slot_id: "atf_sidebar_ad".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(0.50), currency: "USD".to_string(), creative: None, @@ -9535,6 +9565,9 @@ mod tests { "atf_sidebar_ad".to_string(), Bid { slot_id: "atf_sidebar_ad".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(1.00), currency: "USD".to_string(), creative: None, @@ -9580,6 +9613,9 @@ mod tests { "atf_sidebar_ad".to_string(), Bid { slot_id: "atf_sidebar_ad".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(0.50), currency: "USD".to_string(), creative: None, @@ -9624,6 +9660,9 @@ mod tests { "no-price-slot".to_string(), Bid { slot_id: "no-price-slot".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: None, currency: "USD".to_string(), creative: None, diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index f00171527..5950615e9 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -2,10 +2,20 @@ // and parses OpenRTB seatbid responses. Used by both the core requestAds flow // and the Prebid.js trustedServer adapter. -import { parseApsRendererDescriptor } from '../integrations/aps/render'; +import { parseApsRendererDescriptor, validateApsRenderer } from '../integrations/aps/render'; import { log } from './log'; -import type { ApsRendererV1 } from './types'; +import type { + AdmRenderSourceV1, + ApsRendererV1, + AuctionDecisionSetV1, + AuctionSlotFailureReason, + BidRenderSourceV1, + BrowserAuctionBidV1, + BrowserAuctionProjectionV1, + CacheRenderSourceV1, + SlotAuctionDecisionV1, +} from './types'; // --------------------------------------------------------------------------- // Types @@ -68,6 +78,518 @@ export interface AuctionBid { 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[]; +} + +export const MAX_BROWSER_AUCTION_PROJECTION_BYTES = 8 * 1024 * 1024; + +const MAX_AUCTION_RESULTS = 256; +const MAX_TARGETING_ENTRIES = 32; +const MAX_ADM_BYTES = 512 * 1024; +const MAX_URL_BYTES = 4096; +const textEncoder = new TextEncoder(); +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 ownDataObject( + 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 ( + expectedKeys && + (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; +} + +function ownDataArray(value: unknown, maximum: number): unknown[] | undefined { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + if (value.length > maximum || Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const names = Object.getOwnPropertyNames(value); + if (names.length !== value.length + 1 || !names.includes('length')) return undefined; + 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; + } + return value; +} + +function validUnicodeScalars(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + } + return true; +} + +function 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; +} + +function validBoundedString( + value: unknown, + maximumBytes: number, + options: { allowControls?: boolean; maximumScalars?: number } = {} +): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + validUnicodeScalars(value) && + (options.allowControls === true || !hasAsciiControl(value)) && + textEncoder.encode(value).length <= maximumBytes && + (options.maximumScalars === undefined || Array.from(value).length <= options.maximumScalars) + ); +} + +function validDimension(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isFinite(value) && + Number.isInteger(value) && + value >= 1 && + value <= 4096 + ); +} + +function parseRenderSource(value: unknown): BidRenderSourceV1 | undefined { + const record = ownDataObject(value); + if (!record || typeof record.type !== 'string') return undefined; + + if (record.type === 'aps') { + const keys = [ + 'type', + 'version', + 'accountId', + 'bidId', + ...(Object.prototype.hasOwnProperty.call(record, 'creativeId') ? ['creativeId'] : []), + 'tagType', + 'creativeUrl', + 'aaxResponse', + 'width', + 'height', + ]; + if (!ownDataObject(value, keys)) return undefined; + const renderer = validateApsRenderer(value); + 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' || + !cacheIdPattern.test(source.cacheId) || + !validBoundedString(source.fetchUrl, MAX_URL_BYTES) || + !validDimension(source.width) || + !validDimension(source.height) + ) { + 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; + } + return { + type: 'cache', + version: 1, + cacheId: source.cacheId, + fetchUrl: fetchUrl.href, + width: source.width, + height: source.height, + } satisfies CacheRenderSourceV1; + } + + return undefined; +} + +function parseDecisionSet(value: unknown): AuctionDecisionSetV1 | undefined { + const record = ownDataObject(value, ['version', 'auctionId', 'results']); + if (!record || record.version !== 1 || typeof record.auctionId !== 'string') return undefined; + if (!auctionIdPattern.test(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 (const raw of results) { + 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 || + typeof winner.candidateId !== 'string' || + !candidateIdPattern.test(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 = {}; + for (const [key, entry] of entries.sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0 + )) { + if ( + key === 'hb_adid' || + !targetingKeyPattern.test(key) || + !validBoundedString(entry, 160, { maximumScalars: 40 }) + ) { + return undefined; + } + Object.defineProperty(targeting, key, { + value: entry, + enumerable: true, + writable: true, + configurable: true, + }); + } + return targeting; +} + +function parseBrowserBid(value: unknown): BrowserAuctionBidV1 | undefined { + const bid = ownDataObject(value, [ + 'candidateId', + 'slot', + 'provider', + 'upstreamBidId', + 'cpm', + 'currency', + 'targeting', + 'rendererReservationId', + 'renderSource', + ]); + if ( + !bid || + typeof bid.candidateId !== 'string' || + !candidateIdPattern.test(bid.candidateId) || + !validBoundedString(bid.slot, 256) || + typeof bid.provider !== 'string' || + !providerPattern.test(bid.provider) || + !validBoundedString(bid.upstreamBidId, 64) || + typeof bid.cpm !== 'number' || + !Number.isFinite(bid.cpm) || + bid.cpm < 0 || + bid.currency !== 'USD' || + typeof bid.rendererReservationId !== 'string' || + !reservationIdPattern.test(bid.rendererReservationId) + ) { + return undefined; + } + const targeting = parseTargeting(bid.targeting); + const renderSource = parseRenderSource(bid.renderSource); + 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, + }; +} + +/** Validate, canonicalize, and deep-copy a complete browser auction projection. */ +export function parseBrowserAuctionProjectionV1( + value: unknown +): BrowserAuctionProjectionV1 | undefined { + const record = ownDataObject(value, ['version', 'auction', 'bids']); + if (!record || record.version !== 1) return undefined; + const auction = parseDecisionSet(record.auction); + const rawBids = ownDataArray(record.bids, MAX_AUCTION_RESULTS); + if (!auction || !rawBids) return undefined; + const bids: BrowserAuctionBidV1[] = []; + const candidateIds = new Set(); + const reservationIds = new Set(); + for (const raw of rawBids) { + const bid = parseBrowserBid(raw); + if ( + !bid || + candidateIds.has(bid.candidateId) || + reservationIds.has(bid.rendererReservationId) + ) { + return undefined; + } + candidateIds.add(bid.candidateId); + reservationIds.add(bid.rendererReservationId); + bids.push(bid); + } + + const winners = auction.results.filter( + (result): result is Extract => + result.outcome === 'winner' + ); + if ( + winners.length !== bids.length || + winners.some( + (winner, index) => + bids[index]?.candidateId !== winner.candidateId || bids[index]?.slot !== winner.slot + ) + ) { + return undefined; + } + + const projection: BrowserAuctionProjectionV1 = { version: 1, auction, bids }; + if (textEncoder.encode(JSON.stringify(projection)).length > MAX_BROWSER_AUCTION_PROJECTION_BYTES) { + return undefined; + } + return projection; +} + +/** Parse the coordinated-cutover `/auction` wire without activating it in production yet. */ +export function parseTrustedServerAuctionResponseV1( + value: unknown +): TrustedServerAuctionResponseV1 | undefined { + const body = ownDataObject(value, ['id', 'seatbid', 'cur', 'ext']); + if (!body || typeof body.id !== 'string' || body.cur !== 'USD') return undefined; + const responseExt = ownDataObject(body.ext, ['trusted_server']); + const trustedResponseExt = ownDataObject(responseExt?.trusted_server, ['slot_results']); + const auction = parseDecisionSet(trustedResponseExt?.slot_results); + const seatbids = ownDataArray(body.seatbid, MAX_AUCTION_RESULTS); + if (!auction || body.id !== auction.auctionId || !seatbids) return undefined; + + const bids: TrustedServerAuctionBidV1[] = []; + for (const rawSeat of seatbids) { + const seat = ownDataObject(rawSeat, ['seat', 'bid']); + if (!seat || typeof seat.seat !== 'string' || !providerPattern.test(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 || + typeof bid.id !== 'string' || + !reservationIdPattern.test(bid.id) || + !validBoundedString(bid.impid, 256) || + typeof trusted.candidate_id !== 'string' || + !candidateIdPattern.test(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); + 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)) || + textEncoder.encode(JSON.stringify(value)).length > MAX_BROWSER_AUCTION_PROJECTION_BYTES + ) { + 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); + } + + return { auction, bids: orderedBids }; +} + // --------------------------------------------------------------------------- // AdRequest building // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index d4d0eebe9..18f619bc2 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -85,6 +85,47 @@ export interface CacheRenderSourceV1 { 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; +} + +export interface BrowserAuctionProjectionV1 { + version: 1; + auction: AuctionDecisionSetV1; + bids: BrowserAuctionBidV1[]; +} + /** A client-side Prebid bid's generated ad ID bound to its APS render capability. */ export interface ApsPrebidRendererEntry { adUnitCode: string; 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 a47080105..b15d07eb7 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -1,6 +1,13 @@ 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'; function apsRenderer(creativeId?: string) { @@ -19,6 +26,75 @@ 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 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' }, + ], + }, + 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[]) { + return { + version: 1, + auction: { + version: 1, + auctionId: 'auction-large', + results: admLengths.map((_, index) => ({ + slot: `slot-${index}`, + outcome: 'winner' as const, + candidateId: candidateId(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', () => { const units = [ @@ -318,6 +394,430 @@ describe('auction/parseAuctionResponse', () => { }); }); +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('enforces result and bid count boundaries', () => { + expect( + parseBrowserAuctionProjectionV1({ + version: 1, + auction: { version: 1, auctionId: 'auction-empty', results: [] }, + bids: [], + }) + ).toBeDefined(); + + const atLimit = browserProjection(); + atLimit.auction.results = []; + 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.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.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); + } + }); +}); + +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 } }, + }; + } + + 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('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(); + }); +}); + describe('auction/sendAuction', () => { let originalFetch: typeof globalThis.fetch; diff --git a/crates/trusted-server-js/lib/vitest.config.ts b/crates/trusted-server-js/lib/vitest.config.ts index def0e7ddc..02f33a590 100644 --- a/crates/trusted-server-js/lib/vitest.config.ts +++ b/crates/trusted-server-js/lib/vitest.config.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; export default defineConfig({ resolve: { @@ -21,6 +21,10 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, + // This suite deliberately uses node:test + vm so it executes the generated + // ES5 artifact without Vite transforms. CI invokes it separately with + // `node --test`; importing it through Vitest rewrites import.meta.url. + exclude: [...configDefaults.exclude, 'test/contract/aps-renderer-es5.test.mjs'], // Run tests in the main thread to avoid spawning // child processes/workers, which are blocked in this sandbox. threads: false, From c4ce9fcedbf913eaa3e5be1306b8cfc78828905b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:19:58 -0700 Subject: [PATCH 018/194] Project exact renderer identities across auction wires --- .../src/auction/formats.rs | 70 ++- .../src/auction/orchestrator.rs | 42 +- .../trusted-server-core/src/auction/types.rs | 54 +- crates/trusted-server-core/src/publisher.rs | 563 +++++++++++++++++- .../trusted-server-js/lib/src/core/auction.ts | 72 ++- .../trusted-server-js/lib/src/core/config.ts | 77 +++ .../trusted-server-js/lib/src/core/types.ts | 5 + .../lib/src/integrations/gpt/index.ts | 37 +- .../lib/src/integrations/prebid/index.ts | 57 +- .../lib/test/core/auction.test.ts | 82 ++- .../lib/test/core/config.test.ts | 46 ++ .../lib/test/integrations/gpt/ad_init.test.ts | 73 ++- .../test/integrations/prebid/index.test.ts | 103 +++- 13 files changed, 1216 insertions(+), 65 deletions(-) diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index ca8693f7d..bc804a2c4 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -31,10 +31,10 @@ use super::orchestrator::OrchestrationResult; use super::types::{ AdFormat, AdSlot, AuctionDecisionSetV1, AuctionDropReason, AuctionDropReasons, AuctionRequest, AuctionSlotFailureReason, BidRenderSourceV1, BrowserAuctionBidV1, BrowserAuctionProjectionV1, - 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, + 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). @@ -335,6 +335,36 @@ pub(crate) mod coordinated_cutover_v1 { }) } + /// 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'-') } @@ -731,7 +761,8 @@ pub(crate) mod coordinated_cutover_v1 { #[cfg(test)] use coordinated_cutover_v1::{ - canonicalize_browser_auction_projection_v1, serialize_trusted_server_auction_response_v1, + canonicalize_browser_auction_projection_v1, canonicalize_cache_fetch_policy_v1, + serialize_trusted_server_auction_response_v1, }; /// Convert `OrchestrationResult` to `OpenRTB` response format. @@ -2499,6 +2530,35 @@ mod convert_tests { ); } + #[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]); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 9c567b168..1f6c8f14d 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -1,10 +1,8 @@ //! Auction orchestrator for managing multi-provider auctions. -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::Request; -use rand::{RngCore as _, rngs::OsRng}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; @@ -20,28 +18,15 @@ use super::provider::{ }; use super::telemetry::AbandonedProviderCall; use super::types::{ - AuctionContext, AuctionDecisionSetV1, AuctionDropReason, AuctionRequest, AuctionResponse, - AuctionSlotFailureReason, Bid, BidStatus, SlotAuctionDecisionV1, + 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; -/// Injectable CSPRNG boundary for response-local auction 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<(), ()>; -} - -struct SystemAuctionIdentityGenerator; - -impl AuctionIdentityGenerator for SystemAuctionIdentityGenerator { - fn fill(&self, destination: &mut [u8]) -> Result<(), ()> { - OsRng.try_fill_bytes(destination).map_err(|_| ()) - } -} - struct NormalizedProviderResponses { outcomes: Vec, candidates: HashMap, @@ -364,18 +349,15 @@ impl AuctionOrchestrator { } fn mint_candidate_id(&self, issued: &mut HashSet) -> Option { - for _ in 0..=CANDIDATE_ID_COLLISION_RETRIES { - let mut bytes = [0_u8; CANDIDATE_ID_BYTES]; - if self.identity_generator.fill(&mut bytes).is_err() { - return None; - } - let candidate_id = URL_SAFE_NO_PAD.encode(bytes); - debug_assert_eq!(candidate_id.len(), 12); - if issued.insert(candidate_id.clone()) { - return Some(candidate_id); - } - } - None + 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 { diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index b2d01a159..9e03db98f 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -1,10 +1,14 @@ //! Core types for auction requests and responses. -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +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; @@ -16,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 { @@ -557,6 +597,16 @@ pub struct CacheRenderSourceV1 { 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")] diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 3562bfabf..140a820b6 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -19,6 +19,7 @@ //! the streaming route). It is not a content-rewriting concern. use std::borrow::Cow; +use std::collections::{BTreeMap, HashSet}; use std::io::Write; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -37,16 +38,21 @@ use http::{HeaderValue, Method, Request, Response, StatusCode, Uri, header}; use crate::auction::endpoints::{ merge_auction_eids, resolve_auction_eids, resolve_client_auction_eids, }; -use crate::auction::formats::sanitize_publisher_page_url; +use crate::auction::formats::{ + coordinated_cutover_v1::CanonicalBrowserAuctionProjectionV1, sanitize_publisher_page_url, +}; use crate::auction::orchestrator::{ - AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, + AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, OrchestrationResult, }; use crate::auction::telemetry::{ AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events, emit_auction_events_best_effort_lazy, }; use crate::auction::types::{ - AuctionContext, AuctionRequest, Bid, DeviceInfo, PublisherInfo, SiteInfo, UserInfo, + AdmRenderSourceV1, AuctionContext, AuctionIdentityGenerator, AuctionRequest, + AuctionSlotFailureReason, Bid, BidRenderSourceV1, BrowserAuctionBidV1, + BrowserAuctionProjectionV1, CacheFetchPolicyV1, CacheRenderSourceV1, DeviceInfo, PublisherInfo, + SiteInfo, SlotAuctionDecisionV1, UserInfo, mint_response_unique_base64url_identity, }; use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; use crate::constants::{COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT}; @@ -3292,6 +3298,214 @@ fn html_escape_for_script(s: &str) -> String { out } +#[allow( + dead_code, + reason = "pure coordinated-cutover projection is wired to entry points in Task 19" +)] +pub(crate) mod coordinated_cutover_v1 { + use super::*; + + const RENDERER_RESERVATION_BYTES: usize = 16; + const RENDERER_RESERVATION_COLLISION_RETRIES: usize = 8; + + fn cache_policy_base(policy: &CacheFetchPolicyV1) -> Option { + if policy.version != 1 { + return None; + } + let canonical = + crate::auction::formats::coordinated_cutover_v1::canonicalize_cache_fetch_policy_v1( + &policy.base_url, + ) + .ok()?; + url::Url::parse(&canonical.base_url).ok() + } + + fn cache_source_from_legacy_bid( + bid: &Bid, + policy: Option<&CacheFetchPolicyV1>, + ) -> Option { + let policy = policy?; + let mut base = cache_policy_base(policy)?; + let cache_id = bid.cache_id.as_deref()?; + if bid.cache_host.as_deref() != base.host_str() + || bid.cache_path.as_deref() != Some(base.path()) + { + return None; + } + base.query_pairs_mut().append_pair("uuid", cache_id); + Some(BidRenderSourceV1::Cache(CacheRenderSourceV1 { + version: 1, + cache_id: cache_id.to_string(), + fetch_url: base.to_string(), + width: bid.width, + height: bid.height, + })) + } + + fn typed_source_matches_cache_policy( + source: &BidRenderSourceV1, + policy: Option<&CacheFetchPolicyV1>, + ) -> bool { + let BidRenderSourceV1::Cache(source) = source else { + return true; + }; + let Some(mut expected) = policy.and_then(cache_policy_base) else { + return false; + }; + expected + .query_pairs_mut() + .append_pair("uuid", &source.cache_id); + source.fetch_url == expected.as_str() + } + + fn project_render_source( + bid: &Bid, + settings: &Settings, + request_origin: &str, + cache_policy: Option<&CacheFetchPolicyV1>, + ) -> Option { + match (&bid.renderer, &bid.creative, &bid.cache_id) { + (Some(source), None, None) + if bid.cache_host.is_none() + && bid.cache_path.is_none() + && typed_source_matches_cache_policy(source, cache_policy) => + { + Some(source.clone()) + } + (None, Some(raw_creative), _) => { + let priced = crate::creative::expand_auction_price_macro( + raw_creative, + bid.price + .filter(|price| price.is_finite() && *price >= 0.0)?, + ); + let adm = crate::creative::process_inline_auction_creative( + settings, + request_origin, + &priced, + ); + (!adm.is_empty()).then_some(BidRenderSourceV1::Adm(AdmRenderSourceV1 { + version: 1, + adm, + width: bid.width, + height: bid.height, + })) + } + (None, None, Some(_)) => cache_source_from_legacy_bid(bid, cache_policy), + _ => None, + } + } + + fn project_targeting( + bid: &Bid, + cpm: f64, + granularity: PriceGranularity, + ) -> BTreeMap { + BTreeMap::from([ + ("hb_bidder".to_string(), bid.bidder.clone()), + ("hb_pb".to_string(), price_bucket(cpm, granularity)), + ]) + } + + /// Build the exact immutable browser projection without publishing it. + pub(crate) fn build_browser_auction_projection_v1( + result: &OrchestrationResult, + granularity: PriceGranularity, + settings: &Settings, + request_origin: &str, + cache_policy: Option<&CacheFetchPolicyV1>, + identity_generator: &dyn AuctionIdentityGenerator, + ) -> Result> { + let mut reservation_ids = HashSet::new(); + let mut projected_results = Vec::with_capacity(result.decision_set.results.len()); + let mut projected_bids = Vec::new(); + + for decision in &result.decision_set.results { + let SlotAuctionDecisionV1::Winner { slot, candidate_id } = decision else { + projected_results.push(decision.clone()); + continue; + }; + let Some(bid) = result.winning_bids.get(slot).filter(|bid| { + bid.slot_id == *slot && bid.candidate_id.as_deref() == Some(candidate_id.as_str()) + }) else { + projected_results.push(SlotAuctionDecisionV1::Failed { + slot: slot.clone(), + reason: AuctionSlotFailureReason::WinnerNotRenderable, + }); + continue; + }; + let Some(cpm) = bid.price.filter(|price| price.is_finite() && *price >= 0.0) else { + projected_results.push(SlotAuctionDecisionV1::Failed { + slot: slot.clone(), + reason: AuctionSlotFailureReason::WinnerNotRenderable, + }); + continue; + }; + let Some(provider) = bid.candidate_provider.clone() else { + projected_results.push(SlotAuctionDecisionV1::Failed { + slot: slot.clone(), + reason: AuctionSlotFailureReason::WinnerNotRenderable, + }); + continue; + }; + let Some(upstream_bid_id) = bid.bid_id.clone() else { + projected_results.push(SlotAuctionDecisionV1::Failed { + slot: slot.clone(), + reason: AuctionSlotFailureReason::WinnerNotRenderable, + }); + continue; + }; + let Some(render_source) = + project_render_source(bid, settings, request_origin, cache_policy) + else { + projected_results.push(SlotAuctionDecisionV1::Failed { + slot: slot.clone(), + reason: AuctionSlotFailureReason::WinnerNotRenderable, + }); + continue; + }; + let Some(renderer_reservation_id) = mint_response_unique_base64url_identity( + identity_generator, + &mut reservation_ids, + "r1_", + RENDERER_RESERVATION_BYTES, + RENDERER_RESERVATION_COLLISION_RETRIES, + ) else { + projected_results.push(SlotAuctionDecisionV1::Failed { + slot: slot.clone(), + reason: AuctionSlotFailureReason::IdentityGenerationFailed, + }); + continue; + }; + + projected_results.push(decision.clone()); + projected_bids.push(BrowserAuctionBidV1 { + candidate_id: candidate_id.clone(), + slot: slot.clone(), + provider, + upstream_bid_id, + cpm, + currency: bid.currency.clone(), + targeting: project_targeting(bid, cpm, granularity), + renderer_reservation_id, + render_source, + }); + } + + crate::auction::formats::coordinated_cutover_v1::canonicalize_browser_auction_projection_v1( + BrowserAuctionProjectionV1 { + version: 1, + auction: crate::auction::types::AuctionDecisionSetV1 { + version: 1, + auction_id: result.decision_set.auction_id.clone(), + results: projected_results, + }, + bids: projected_bids, + }, + request_origin, + ) + } +} + /// Build a price-bucketed bid map from winning bids. /// /// Returns a JSON object map of slot ID → bid metadata including the bucketed @@ -4203,6 +4417,349 @@ mod tests { } } + mod coordinated_cutover_projection_tests { + use std::collections::{HashMap, VecDeque}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + use crate::auction::types::{ + AdmRenderSourceV1, ApsRendererV1, ApsTagType, AuctionIdentityGenerator, + AuctionSlotFailureReason, BidRenderSourceV1, CacheFetchPolicyV1, CacheRenderSourceV1, + SlotAuctionDecisionV1, + }; + use crate::price_bucket::PriceGranularity; + use base64::Engine as _; + + struct ScriptedIdentityGenerator { + draws: Mutex>>, + count: AtomicUsize, + } + + impl ScriptedIdentityGenerator { + fn new(draws: impl IntoIterator>) -> Self { + Self { + draws: Mutex::new(draws.into_iter().collect()), + count: AtomicUsize::new(0), + } + } + } + + impl AuctionIdentityGenerator for ScriptedIdentityGenerator { + fn fill(&self, destination: &mut [u8]) -> Result<(), ()> { + self.count.fetch_add(1, Ordering::SeqCst); + let draw = self + .draws + .lock() + .expect("should lock scripted draws") + .pop_front() + .ok_or(())?; + if draw.len() != destination.len() { + return Err(()); + } + destination.copy_from_slice(&draw); + Ok(()) + } + } + + 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(), + } + } + + 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 + } + + 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(), + } + } + + #[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]]); + + let canonical = coordinated_cutover_v1::build_browser_auction_projection_v1( + &result, + PriceGranularity::Dense, + &Settings::default(), + "https://publisher.example", + None, + &generator, + ) + .expect("valid winner should project"); + + 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 + ); + } + + #[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, + &generator, + ) + .expect("collision exhaustion should remain a per-slot decision"); + + 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, + } + ); + } + + #[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]]); + + let canonical = coordinated_cutover_v1::build_browser_auction_projection_v1( + &result, + PriceGranularity::Dense, + &Settings::default(), + "https://publisher.example", + None, + &generator, + ) + .expect("valid APS winner should project"); + + 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") + ); + } + + #[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), + &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, + }) + ); + + let without_policy = coordinated_cutover_v1::build_browser_auction_projection_v1( + &result, + PriceGranularity::Dense, + &Settings::default(), + "https://publisher.example", + 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, + } + ); + } + + #[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, + &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 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, + &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 { diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index 5950615e9..f8ad13351 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -4,6 +4,7 @@ import { parseApsRendererDescriptor, validateApsRenderer } from '../integrations/aps/render'; +import { parseCacheFetchPolicyV1 } from './config'; import { log } from './log'; import type { AdmRenderSourceV1, @@ -13,6 +14,7 @@ import type { BidRenderSourceV1, BrowserAuctionBidV1, BrowserAuctionProjectionV1, + CacheFetchPolicyV1, CacheRenderSourceV1, SlotAuctionDecisionV1, } from './types'; @@ -121,6 +123,11 @@ const auctionFailureReasons = new Set([ 'internal_error', ]); +/** Whether a value is one exact server-minted renderer reservation identity. */ +export function isRendererReservationIdV1(value: unknown): value is string { + return typeof value === 'string' && reservationIdPattern.test(value); +} + function ownDataObject( value: unknown, expectedKeys?: readonly string[] @@ -201,7 +208,10 @@ function validDimension(value: unknown): value is number { ); } -function parseRenderSource(value: unknown): BidRenderSourceV1 | undefined { +function parseRenderSource( + value: unknown, + cachePolicy?: Readonly +): BidRenderSourceV1 | undefined { const record = ownDataObject(value); if (!record || typeof record.type !== 'string') return undefined; @@ -271,7 +281,8 @@ function parseRenderSource(value: unknown): BidRenderSourceV1 | undefined { !cacheIdPattern.test(source.cacheId) || !validBoundedString(source.fetchUrl, MAX_URL_BYTES) || !validDimension(source.width) || - !validDimension(source.height) + !validDimension(source.height) || + !cachePolicy ) { return undefined; } @@ -292,6 +303,22 @@ function parseRenderSource(value: unknown): BidRenderSourceV1 | undefined { ) { 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, @@ -382,7 +409,10 @@ function parseTargeting(value: unknown): Record | undefined { return targeting; } -function parseBrowserBid(value: unknown): BrowserAuctionBidV1 | undefined { +function parseBrowserBid( + value: unknown, + cachePolicy?: Readonly +): BrowserAuctionBidV1 | undefined { const bid = ownDataObject(value, [ 'candidateId', 'slot', @@ -406,13 +436,12 @@ function parseBrowserBid(value: unknown): BrowserAuctionBidV1 | undefined { !Number.isFinite(bid.cpm) || bid.cpm < 0 || bid.currency !== 'USD' || - typeof bid.rendererReservationId !== 'string' || - !reservationIdPattern.test(bid.rendererReservationId) + !isRendererReservationIdV1(bid.rendererReservationId) ) { return undefined; } const targeting = parseTargeting(bid.targeting); - const renderSource = parseRenderSource(bid.renderSource); + const renderSource = parseRenderSource(bid.renderSource, cachePolicy); if (!targeting || !renderSource) return undefined; return { candidateId: bid.candidateId, @@ -429,8 +458,12 @@ function parseBrowserBid(value: unknown): BrowserAuctionBidV1 | undefined { /** Validate, canonicalize, and deep-copy a complete browser auction projection. */ export function parseBrowserAuctionProjectionV1( - value: unknown + value: unknown, + cachePolicyValue?: unknown ): BrowserAuctionProjectionV1 | undefined { + const cachePolicy = + cachePolicyValue === undefined ? undefined : parseCacheFetchPolicyV1(cachePolicyValue); + if (cachePolicyValue !== undefined && !cachePolicy) return undefined; const record = ownDataObject(value, ['version', 'auction', 'bids']); if (!record || record.version !== 1) return undefined; const auction = parseDecisionSet(record.auction); @@ -440,7 +473,7 @@ export function parseBrowserAuctionProjectionV1( const candidateIds = new Set(); const reservationIds = new Set(); for (const raw of rawBids) { - const bid = parseBrowserBid(raw); + const bid = parseBrowserBid(raw, cachePolicy); if ( !bid || candidateIds.has(bid.candidateId) || @@ -468,7 +501,9 @@ export function parseBrowserAuctionProjectionV1( } const projection: BrowserAuctionProjectionV1 = { version: 1, auction, bids }; - if (textEncoder.encode(JSON.stringify(projection)).length > MAX_BROWSER_AUCTION_PROJECTION_BYTES) { + if ( + textEncoder.encode(JSON.stringify(projection)).length > MAX_BROWSER_AUCTION_PROJECTION_BYTES + ) { return undefined; } return projection; @@ -476,8 +511,12 @@ export function parseBrowserAuctionProjectionV1( /** Parse the coordinated-cutover `/auction` wire without activating it in production yet. */ export function parseTrustedServerAuctionResponseV1( - value: unknown + 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']); @@ -489,7 +528,8 @@ export function parseTrustedServerAuctionResponseV1( const bids: TrustedServerAuctionBidV1[] = []; for (const rawSeat of seatbids) { const seat = ownDataObject(rawSeat, ['seat', 'bid']); - if (!seat || typeof seat.seat !== 'string' || !providerPattern.test(seat.seat)) return undefined; + if (!seat || typeof seat.seat !== 'string' || !providerPattern.test(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) { @@ -513,8 +553,7 @@ export function parseTrustedServerAuctionResponseV1( if ( !bid || !trusted || - typeof bid.id !== 'string' || - !reservationIdPattern.test(bid.id) || + !isRendererReservationIdV1(bid.id) || !validBoundedString(bid.impid, 256) || typeof trusted.candidate_id !== 'string' || !candidateIdPattern.test(trusted.candidate_id) || @@ -527,7 +566,7 @@ export function parseTrustedServerAuctionResponseV1( ) { return undefined; } - const renderSource = parseRenderSource(trusted.render_source); + const renderSource = parseRenderSource(trusted.render_source, cachePolicy); if (!renderSource || renderSource.width !== bid.w || renderSource.height !== bid.h) { return undefined; } @@ -562,10 +601,7 @@ export function parseTrustedServerAuctionResponseV1( if ( winners.length !== bids.length || bids.some((bid) => { - if ( - candidates.has(bid.candidateId) || - reservations.has(bid.rendererReservationId) - ) { + if (candidates.has(bid.candidateId) || reservations.has(bid.rendererReservationId)) { return true; } candidates.add(bid.candidateId); diff --git a/crates/trusted-server-js/lib/src/core/config.ts b/crates/trusted-server-js/lib/src/core/config.ts index 7026c4420..a90b7db68 100644 --- a/crates/trusted-server-js/lib/src/core/config.ts +++ b/crates/trusted-server-js/lib/src/core/config.ts @@ -1,6 +1,7 @@ // Global configuration storage for the tsjs runtime (logging, debug, etc.). import { log } from './log'; import type { LogLevel } from './log'; +import type { CacheFetchPolicyV1 } from './types'; export interface Config { debug?: boolean; @@ -24,3 +25,79 @@ export function setConfig(cfg: Config): void { export function getConfig(): Config { return { ...CONFIG }; } + +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; +} + +function validUnicodeScalars(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + } + return true; +} + +function 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, 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/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 18f619bc2..a337ad048 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -83,6 +83,11 @@ export interface CacheRenderSourceV1 { height: number; } +export interface CacheFetchPolicyV1 { + version: 1; + baseUrl: string; +} + export type BidRenderSourceV1 = ApsRendererV1 | AdmRenderSourceV1 | CacheRenderSourceV1; export type AuctionSlotFailureReason = diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 7be05e10c..ed81b6d53 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,6 +1,13 @@ import { log } from '../../core/log'; +import { isRendererReservationIdV1 } from '../../core/auction'; import { isEffectivelyVisible, recordRender, stampCreativeTrace } from '../../core/trace'; -import type { AuctionSlot, AuctionBidData, GptSlotHandoff, TsjsApi } from '../../core/types'; +import type { + AuctionSlot, + AuctionBidData, + BrowserAuctionBidV1, + GptSlotHandoff, + TsjsApi, +} from '../../core/types'; import { APS_UNIVERSAL_CREATIVE_RENDERER, APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, @@ -42,6 +49,34 @@ const TS_BID_TARGETING_KEYS = [ 'hb_cache_path', ] as const; const TS_BASE_TARGETING_KEYS = [...TS_BID_TARGETING_KEYS, TS_INITIAL_TARGETING_KEY] as const; +/** Prepare dormant hard-cutover GPT targeting without mutating a live slot or bid. */ +export function prepareTrustedServerGptTargetingV1( + bid: BrowserAuctionBidV1 +): Readonly> | undefined { + if ( + !isRendererReservationIdV1(bid.rendererReservationId) || + !['aps', 'adm', 'cache'].includes(bid.renderSource.type) || + typeof bid.targeting !== 'object' || + bid.targeting === null || + Array.isArray(bid.targeting) || + Object.getPrototypeOf(bid.targeting) !== Object.prototype || + Object.prototype.hasOwnProperty.call(bid.targeting, 'hb_adid') + ) { + return undefined; + } + + const targeting: Record = { hb_adid: bid.rendererReservationId }; + for (const [key, value] of Object.entries(bid.targeting)) { + if (typeof value !== 'string') return undefined; + Object.defineProperty(targeting, key, { + value, + enumerable: true, + writable: false, + configurable: false, + }); + } + return Object.freeze(targeting); +} function bumpRenderGeneration(ts: TsjsApi): number { const next = (ts.renderGeneration ?? 0) + 1; diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index df0cccc63..4a25670ff 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -15,10 +15,14 @@ import type _pbjsDefault from 'prebid.js'; import { log } from '../../core/log'; import { isEffectivelyVisible, recordRender, stampCreativeTrace } from '../../core/trace'; -import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; +import { + buildAdRequest, + isRendererReservationIdV1, + parseAuctionResponse, +} from '../../core/auction'; import { registerApsPrebidRenderer, validateApsRenderer } from '../aps/render'; import type { AuctionBid, AuctionEid } from '../../core/auction'; -import type { AuctionSlot, RenderRecord } from '../../core/types'; +import type { AuctionSlot, BrowserAuctionBidV1, RenderRecord } from '../../core/types'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; @@ -287,6 +291,55 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { // trustedServer bid adapter helpers // --------------------------------------------------------------------------- +/** + * Prepare a generated Trusted Server Prebid bid for the hard cutover without + * publishing it or mutating Prebid-owned state. Native bids never enter this + * boundary and therefore retain their Prebid-generated identities. + */ +export function prepareTrustedServerPrebidBidV1( + bid: BrowserAuctionBidV1, + generatedBid: Readonly> +): Readonly> | undefined { + if ( + !isRendererReservationIdV1(bid.rendererReservationId) || + !['aps', 'adm', 'cache'].includes(bid.renderSource.type) || + typeof generatedBid !== 'object' || + generatedBid === null || + Array.isArray(generatedBid) || + Object.getPrototypeOf(generatedBid) !== Object.prototype + ) { + return undefined; + } + + const descriptors = Object.getOwnPropertyDescriptors(generatedBid); + const keys = Reflect.ownKeys(generatedBid); + const adId = descriptors['adId']; + if ( + keys.some((key) => typeof key !== 'string') || + !adId || + !Object.prototype.hasOwnProperty.call(adId, 'value') || + typeof adId.value !== 'string' || + adId.value.length === 0 + ) { + return undefined; + } + + const prepared: Record = {}; + for (const key of keys as string[]) { + const descriptor = descriptors[key]; + if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + return undefined; + } + Object.defineProperty(prepared, key, { + value: key === 'adId' ? bid.rendererReservationId : descriptor.value, + enumerable: descriptor.enumerable === true, + writable: false, + configurable: false, + }); + } + return Object.freeze(prepared); +} + /** Resolved endpoint — set by installPrebidNpm, read by the adapter. */ let auctionEndpoint = '/auction'; 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 b15d07eb7..36f844587 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -9,6 +9,8 @@ import { 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]!; @@ -627,7 +629,9 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { 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(Object.prototype.hasOwnProperty.call(parsed!.bids[0]!.targeting, '__proto__')).toBe( + true + ); expect(parsed!.bids[0]!.targeting['__proto__']).toBe('publisher-value'); }); @@ -652,6 +656,46 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { expect(parseBrowserAuctionProjectionV1(value) !== undefined).toBe(accepted); } }); + + 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', () => { @@ -796,7 +840,13 @@ describe('auction/parseTrustedServerAuctionResponseV1', () => { 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 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; @@ -816,6 +866,34 @@ describe('auction/parseTrustedServerAuctionResponseV1', () => { 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(); + }); }); describe('auction/sendAuction', () => { 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..120bb43ac 100644 --- a/crates/trusted-server-js/lib/test/core/config.test.ts +++ b/crates/trusted-server-js/lib/test/core/config.test.ts @@ -19,4 +19,50 @@ describe('config', () => { setConfig({ logLevel: 'info' }); expect(log.getLevel()).toBe('info'); }); + + 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('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'; + + 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/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 1b3d63ff2..71ee3bc5f 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -4,7 +4,12 @@ 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 { GptSlotHandoff, TsjsApi } from '../../../src/core/types'; +import type { + BidRenderSourceV1, + BrowserAuctionBidV1, + GptSlotHandoff, + TsjsApi, +} from '../../../src/core/types'; function apsRenderer() { const bid = envelope.seatbid[0]!.bid[0]!; @@ -22,6 +27,72 @@ function apsRenderer() { }; } +describe('prepareTrustedServerGptTargetingV1', () => { + function projectedBid(renderSource: BidRenderSourceV1): BrowserAuctionBidV1 { + return { + candidateId: 'AAAAAAAAAAAA', + slot: 'slot-1', + provider: 'prebid', + upstreamBidId: 'upstream-bid', + cpm: 1.25, + currency: 'USD', + targeting: { hb_bidder: 'example', hb_pb: '1.25' }, + rendererReservationId: 'r1_AAAAAAAAAAAAAAAAAAAAAA', + renderSource, + }; + } + + it('uses the exact renderer reservation as hb_adid for APS, ADM, and cache', async () => { + const { prepareTrustedServerGptTargetingV1 } = + await import('../../../src/integrations/gpt/index'); + const sources: BidRenderSourceV1[] = [ + apsRenderer(), + { type: 'adm', version: 1, adm: '
ad
', width: 300, height: 250 }, + { + type: 'cache', + version: 1, + cacheId: 'f47447a0-b759-4f2f-9887-af458b79b570', + fetchUrl: 'https://cache.example/pbc/v1/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570', + width: 300, + height: 250, + }, + ]; + + for (const source of sources) { + const bid = projectedBid(source); + const targeting = prepareTrustedServerGptTargetingV1(bid); + expect(targeting).toEqual({ + hb_adid: 'r1_AAAAAAAAAAAAAAAAAAAAAA', + hb_bidder: 'example', + hb_pb: '1.25', + }); + expect(bid.targeting).toEqual({ hb_bidder: 'example', hb_pb: '1.25' }); + } + }); + + it('rejects malformed reservations without truncating or falling back to other ids', async () => { + const { prepareTrustedServerGptTargetingV1 } = + await import('../../../src/integrations/gpt/index'); + const malformed = projectedBid({ + type: 'cache', + version: 1, + cacheId: 'f47447a0-b759-4f2f-9887-af458b79b570', + fetchUrl: 'https://cache.example/pbc/v1/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570', + width: 300, + height: 250, + }); + malformed.rendererReservationId = `r1_${'A'.repeat(23)}`; + malformed.upstreamBidId = 'fallback-upstream'; + + expect(prepareTrustedServerGptTargetingV1(malformed)).toBeUndefined(); + expect(malformed.rendererReservationId).toHaveLength(26); + + const prepopulated = projectedBid(apsRenderer()); + prepopulated.targeting.hb_adid = 'forbidden-fallback'; + expect(prepareTrustedServerGptTargetingV1(prepopulated)).toBeUndefined(); + }); +}); + // 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 diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 7dc51dd9b..f8bb92c26 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -194,9 +194,10 @@ import { auctionBidsToPrebidBids, installPrebidNpm, installRefreshHandler, + prepareTrustedServerPrebidBidV1, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; -import type { TsjsApi } from '../../../src/core/types'; +import type { BidRenderSourceV1, BrowserAuctionBidV1, TsjsApi } from '../../../src/core/types'; import { log } from '../../../src/core/log'; import envelope from '../../fixtures/aps-renderer-v1.json'; @@ -206,6 +207,106 @@ beforeEach(() => { delete testWindow.__tsjsPrebidShimInstalled; }); +describe('prebid/prepareTrustedServerPrebidBidV1', () => { + const reservationId = 'r1_BwcHBwcHBwcHBwcHBwcHBw'; + const renderSources: BidRenderSourceV1[] = [ + { + type: 'aps', + version: 1, + accountId: 'example-account-id', + bidId: 'example-aps-bid-id', + creativeId: 'example-creative-id', + tagType: 'iframe', + creativeUrl: 'https://c.amazon-adsystem.com/example', + aaxResponse: 'eyJpZCI6ImV4YW1wbGUifQ==', + width: 300, + height: 250, + }, + { type: 'adm', version: 1, adm: '
trusted creative
', width: 300, height: 250 }, + { + type: 'cache', + version: 1, + cacheId: '123e4567-e89b-42d3-a456-426614174000', + fetchUrl: 'https://cache.example.test/cache?uuid=123e4567-e89b-42d3-a456-426614174000', + width: 300, + height: 250, + }, + ]; + + function projectedBid(renderSource: BidRenderSourceV1): BrowserAuctionBidV1 { + return { + candidateId: 'AQIDBAUGBwgJ', + slot: 'homepage_header', + provider: 'example', + upstreamBidId: 'upstream-bid-id', + cpm: 1.25, + currency: 'USD', + targeting: Object.freeze({ hb_bidder: 'example', hb_pb: '1.20' }), + rendererReservationId: reservationId, + renderSource, + }; + } + + it.each(renderSources)( + 'replaces only the generated Trusted Server adId for $type rendering', + (renderSource) => { + const generatedTsBid = { adId: 'prebid-generated-id', cpm: 1.25, bidder: 'trustedServer' }; + const nativeBid = { adId: 'native-prebid-id', cpm: 2.5, bidder: 'nativeBidder' }; + + const prepared = prepareTrustedServerPrebidBidV1(projectedBid(renderSource), generatedTsBid); + + expect(prepared).toEqual({ + adId: reservationId, + cpm: 1.25, + bidder: 'trustedServer', + }); + expect(Object.isFrozen(prepared)).toBe(true); + expect(generatedTsBid.adId).toBe('prebid-generated-id'); + expect(nativeBid.adId).toBe('native-prebid-id'); + } + ); + + it('rejects malformed reservations instead of truncating or falling back to other ids', () => { + const bid = projectedBid(renderSources[0]!); + const generatedTsBid = { + adId: 'prebid-generated-id', + bidId: 'upstream-fallback-id', + cacheId: '123e4567-e89b-42d3-a456-426614174000', + }; + + expect( + prepareTrustedServerPrebidBidV1( + { ...bid, rendererReservationId: `${reservationId}extra` }, + generatedTsBid + ) + ).toBeUndefined(); + expect(generatedTsBid).toEqual({ + adId: 'prebid-generated-id', + bidId: 'upstream-fallback-id', + cacheId: '123e4567-e89b-42d3-a456-426614174000', + }); + }); + + it('rejects accessor and non-plain generated bids before reading or mutating them', () => { + const accessor = vi.fn(() => 'prebid-generated-id'); + const generatedTsBid = Object.defineProperty({}, 'adId', { + get: accessor, + enumerable: true, + }); + + expect( + prepareTrustedServerPrebidBidV1(projectedBid(renderSources[1]!), generatedTsBid) + ).toBeUndefined(); + expect(accessor).not.toHaveBeenCalled(); + expect( + prepareTrustedServerPrebidBidV1( + projectedBid(renderSources[1]!), + Object.create({ adId: 'inherited-id' }) as Record + ) + ).toBeUndefined(); + }); +}); + describe('prebid/collectBidders', () => { it('returns empty array for empty ad units', () => { expect(collectBidders([])).toEqual([]); From 5d663961d3439fc4924675df3a8ff91f145fb233 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:43:29 -0700 Subject: [PATCH 019/194] feat(aps): proxy the live creative runner safely --- .github/workflows/integration-tests.yml | 94 ++ .tool-versions | 2 +- CLAUDE.md | 4 +- Cargo.lock | 2 + crates/trusted-server-adapter-axum/Cargo.toml | 6 +- crates/trusted-server-adapter-axum/src/app.rs | 89 ++ .../trusted-server-adapter-axum/src/main.rs | 61 ++ .../src/platform.rs | 294 +++++- .../tests/routes.rs | 128 ++- .../Cargo.toml | 1 + .../build.sh | 7 +- .../src/app.rs | 47 + .../src/lib.rs | 48 + .../src/platform.rs | 229 ++++- .../tests/routes.rs | 95 +- .../wrangler.aps-runner-proxy.toml | 16 + .../trusted-server-adapter-fastly/Cargo.toml | 5 + .../trusted-server-adapter-fastly/src/app.rs | 119 +++ .../trusted-server-adapter-fastly/src/main.rs | 28 +- .../src/platform.rs | 213 ++++- crates/trusted-server-adapter-spin/Cargo.toml | 1 + crates/trusted-server-adapter-spin/src/app.rs | 66 ++ crates/trusted-server-adapter-spin/src/lib.rs | 11 + .../src/platform.rs | 242 ++++- .../tests/routes.rs | 95 +- .../src/integrations/aps.rs | 882 +++++++++++++++++- .../src/integrations/mod.rs | 26 + .../src/integrations/registry.rs | 94 ++ .../trusted-server-core/src/platform/http.rs | 74 ++ .../trusted-server-core/src/platform/mod.rs | 5 +- .../src/platform/test_support.rs | 94 +- .../trusted-server-core/src/platform/types.rs | 10 + .../Cargo.toml | 16 +- .../README.md | 2 +- .../browser/fixtures/fictional-aps-runner.js | 57 ++ .../browser/tests/shared/aps-renderer.spec.ts | 235 +++++ .../cloudflare/aps-runner-proxy-service.js | 35 + .../cloudflare-aps-runner-proxy-fixture.toml | 7 + .../configs/spin-aps-runner-proxy.toml | 22 + .../tests/aps_runner_proxy.rs | 491 ++++++++++ .../tests/common/aps_runner_upstream.rs | 297 ++++++ .../tests/common/mod.rs | 2 + .../tests/common/runtime.rs | 15 + .../tests/environments/axum.rs | 51 +- .../tests/environments/cloudflare.rs | 227 ++++- .../tests/environments/fastly.rs | 201 +++- .../tests/environments/mod.rs | 48 +- .../tests/environments/spin.rs | 175 ++++ .../tests/parity.rs | 95 ++ docs/guide/error-reference.md | 2 +- docs/guide/getting-started.md | 2 +- docs/guide/testing.md | 2 +- ...8-04-aps-tsjs-resilience-implementation.md | 75 +- scripts/integration-tests-aps-runner-proxy.sh | 198 ++++ scripts/integration-tests-browser.sh | 16 +- scripts/integration-tests.sh | 2 +- 56 files changed, 5291 insertions(+), 70 deletions(-) create mode 100644 crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml create mode 100644 crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js create mode 100644 crates/trusted-server-integration-tests/fixtures/cloudflare/aps-runner-proxy-service.js create mode 100644 crates/trusted-server-integration-tests/fixtures/configs/cloudflare-aps-runner-proxy-fixture.toml create mode 100644 crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml create mode 100644 crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs create mode 100644 crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs create mode 100644 crates/trusted-server-integration-tests/tests/environments/spin.rs create mode 100755 scripts/integration-tests-aps-runner-proxy.sh diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 55db2a176..48c82f451 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -159,6 +159,54 @@ 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 @@ -277,3 +325,49 @@ jobs: path: crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json if-no-files-found: error retention-days: 30 + + browser-tests-aps-v1: + name: browser integration tests (APS v1 feature artifact) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Set up APS v1 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 v1 Chromium test with explicit feature artifact + env: + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + TS_BROWSER_FRAMEWORKS: nextjs + TS_TEST_APS_V1: "1" + run: >- + ./scripts/integration-tests-browser.sh + tests/shared/aps-renderer.spec.ts + --project=chromium + --grep="uses one port, reports ordered progress, and fails closed" + + - name: Upload APS v1 Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-aps-v1 + path: crates/trusted-server-integration-tests/browser/playwright-report/ + retention-days: 7 diff --git a/.tool-versions b/.tool-versions index 758146800..5330e3de6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,5 +1,5 @@ fastly 15.1.0 rust 1.95.0 nodejs 24.12.0 -viceroy 0.17.0 +viceroy 0.19.0 wasmtime 44.0.1 diff --git a/CLAUDE.md b/CLAUDE.md index e2c27a673..e18ab72c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,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`) | --- @@ -138,7 +138,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..99225c9cc 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", 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..ee7f57b50 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -71,6 +71,9 @@ fn build_state_with_settings( settings: Settings, ) -> Result, Report> { let orchestrator = build_orchestrator(&settings)?; + #[cfg(feature = "aps-runner-proxy-integration-test")] + let registry = IntegrationRegistry::new_with_aps_v1_for_tests(&settings)?; + #[cfg(not(feature = "aps-runner-proxy-integration-test"))] let registry = IntegrationRegistry::new(&settings)?; Ok(Arc::new(AppState { @@ -80,6 +83,92 @@ fn build_state_with_settings( })) } +#[cfg(feature = "aps-runner-proxy-integration-test")] +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 coordinated-cutover handler") + .unwrap_or_else(|report| http_error(&report)), + ) +} + +#[cfg(feature = "aps-runner-proxy-integration-test")] +#[derive(Clone)] +/// Feature-artifact dispatcher that owns one startup-built APS registry. +pub struct ReservedApsDispatcher { + state: Arc, +} + +#[cfg(feature = "aps-runner-proxy-integration-test")] +impl ReservedApsDispatcher { + /// Build the dispatcher from the adapter's startup settings. + /// + /// # Errors + /// + /// Returns an error when settings, the orchestrator, or the APS test + /// 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 APS test 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 + } +} + +#[cfg(feature = "aps-runner-proxy-integration-test")] +/// Dispatch a reserved APS request using explicit settings. +/// +/// # Errors +/// +/// Returns an error when the feature-only 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) +} + +#[cfg(feature = "aps-runner-proxy-integration-test")] +/// Dispatch a reserved APS request using startup settings. +/// +/// # Errors +/// +/// Returns an error when startup settings or the feature-only dispatcher +/// cannot be initialized. +pub async fn dispatch_reserved( + req: Request, +) -> Result, Report> { + Ok(ReservedApsDispatcher::from_startup_settings()? + .dispatch(req) + .await) +} + // --------------------------------------------------------------------------- // Error helper // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 960982176..8e22dedd4 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,7 +1,9 @@ +#[cfg(not(feature = "aps-runner-proxy-integration-test"))] use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; use edgezero_core::app::Hooks as _; use trusted_server_adapter_axum::app::TrustedServerApp; +#[cfg(not(feature = "aps-runner-proxy-integration-test"))] #[allow(clippy::print_stderr)] fn main() { if let Err(e) = simple_logger::SimpleLogger::new().init() { @@ -27,6 +29,65 @@ fn main() { } } +#[cfg(feature = "aps-runner-proxy-integration-test")] +#[tokio::main] +#[allow(clippy::print_stderr)] +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}"); + } + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port_from_env().unwrap_or(8787))); + let dispatcher = + trusted_server_adapter_axum::app::ReservedApsDispatcher::from_startup_settings() + .expect("APS feature artifact should build its reserved 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 route 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(addr) + .await + .expect("APS feature artifact should bind its configured address"); + log::info!("Listening on http://{addr}"); + if let Err(error) = axum::serve(listener, app).await { + log::error!("trusted-server-adapter-axum failed: {error}"); + } +} + /// Read a port number from the `PORT` environment variable. /// /// Returns `None` when the variable is unset. Exits non-zero if the value diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index a511daab2..c823fbc41 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,120 @@ 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 = builder + .send() + .await + .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(); + while let Some(chunk) = response + .chunk() + .await + .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); + } + Ok(RawProxyResponseV1 { evidence, body }) + }) + .await + .map_err(|_| { + Report::new(PlatformError::HttpClient).attach("raw proxy total deadline exceeded") + })? + } } impl Default for AxumPlatformHttpClient { @@ -397,6 +547,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 +914,140 @@ 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 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..c1fc7e28f 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,34 @@ 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") } +#[cfg(feature = "aps-runner-proxy-integration-test")] +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()) } @@ -208,6 +233,101 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { ); } +#[cfg(feature = "aps-runner-proxy-integration-test")] +#[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/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..fe8b618f1 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -109,6 +109,9 @@ fn build_state_with_settings( settings: Settings, ) -> Result, Report> { let orchestrator = build_orchestrator(&settings)?; + #[cfg(feature = "aps-runner-proxy-integration-test")] + let registry = IntegrationRegistry::new_with_aps_v1_for_tests(&settings)?; + #[cfg(not(feature = "aps-runner-proxy-integration-test"))] let registry = IntegrationRegistry::new(&settings)?; Ok(Arc::new(AppState { @@ -118,6 +121,50 @@ fn build_state_with_settings( })) } +#[cfg(feature = "aps-runner-proxy-integration-test")] +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 coordinated-cutover handler") + .unwrap_or_else(|report| http_error(&report)), + ) +} + +#[cfg(feature = "aps-runner-proxy-integration-test")] +/// 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) +} + +#[cfg(feature = "aps-runner-proxy-integration-test")] +/// 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 // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-cloudflare/src/lib.rs b/crates/trusted-server-adapter-cloudflare/src/lib.rs index 2ce435b17..b28f40cbb 100644 --- a/crates/trusted-server-adapter-cloudflare/src/lib.rs +++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs @@ -15,6 +15,14 @@ pub mod platform; #[cfg(target_arch = "wasm32")] use worker::{Context, Env, Request, Response, Result, event}; +#[cfg(all( + feature = "aps-runner-proxy-integration-test", + 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 +36,33 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { app::set_cloudflare_config_json(config.to_string()); } + #[cfg(feature = "aps-runner-proxy-integration-test")] + let is_reserved = req + .url() + .is_ok_and(|url| trusted_server_core::integrations::aps::is_aps_family_path(url.path())); + #[cfg(feature = "aps-runner-proxy-integration-test")] + 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 coordinated-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 +71,16 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { } } } + +#[cfg(all(test, feature = "aps-runner-proxy-integration-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..dbbea3288 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,14 @@ async fn route(router: RouterService, req: Request) -> Response { router.oneshot(req).await.expect("should route request") } +#[cfg(feature = "aps-runner-proxy-integration-test")] +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 +121,73 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } +#[cfg(feature = "aps-runner-proxy-integration-test")] +#[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/v2", 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. 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..ab1260257 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -165,6 +165,26 @@ pub(crate) fn build_state() -> Result, Report> build_state_from_settings(load_settings_from_config_store()?) } +#[cfg(feature = "aps-runner-proxy-integration-test")] +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 coordinated-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(); @@ -177,6 +197,9 @@ pub(crate) fn build_state_from_settings( warn_if_certificate_check_disabled(&settings); let orchestrator = build_orchestrator(&settings)?; + #[cfg(feature = "aps-runner-proxy-integration-test")] + let registry = IntegrationRegistry::new_with_aps_v1_for_tests(&settings)?; + #[cfg(not(feature = "aps-runner-proxy-integration-test"))] let registry = IntegrationRegistry::new(&settings)?; let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings); @@ -1246,6 +1269,8 @@ 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, @@ -1352,6 +1377,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 +1404,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 +1423,90 @@ 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/v2", + 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. diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 8fec21435..352506874 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -167,7 +167,23 @@ 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)) { + #[cfg(feature = "aps-runner-proxy-integration-test")] + 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)) + }; + #[cfg(not(feature = "aps-runner-proxy-integration-test"))] + let routed = futures::executor::block_on(app.router().oneshot(core_req)); + match routed { Ok(response) => response, Err(error) => edge_error_response(error), } @@ -186,7 +202,15 @@ 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) { + #[cfg(feature = "aps-runner-proxy-integration-test")] + let should_finalize = response + .extensions() + .get::() + .is_none() + && !take_finalize_sentinel(&mut response); + #[cfg(not(feature = "aps-runner-proxy-integration-test"))] + let should_finalize = !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..0ce351336 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; @@ -50,11 +52,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 @@ -65,6 +82,9 @@ fn build_state_with_settings( settings: Settings, ) -> Result, Report> { let orchestrator = build_orchestrator(&settings)?; + #[cfg(feature = "aps-runner-proxy-integration-test")] + let registry = IntegrationRegistry::new_with_aps_v1_for_tests(&settings)?; + #[cfg(not(feature = "aps-runner-proxy-integration-test"))] let registry = IntegrationRegistry::new(&settings)?; Ok(Arc::new(AppState { @@ -74,6 +94,52 @@ fn build_state_with_settings( })) } +#[cfg(feature = "aps-runner-proxy-integration-test")] +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 coordinated-cutover handler") + .unwrap_or_else(|report| http_error(&report)), + ) +} + +#[cfg(feature = "aps-runner-proxy-integration-test")] +/// Dispatch a reserved APS request using explicit settings. +/// +/// # Errors +/// +/// Returns an error when the feature-only 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) +} + +#[cfg(feature = "aps-runner-proxy-integration-test")] +/// Dispatch a reserved APS request using startup settings. +/// +/// # Errors +/// +/// Returns an error when startup settings or the feature-only 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 // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-spin/src/lib.rs b/crates/trusted-server-adapter-spin/src/lib.rs index f47877ff2..bb43c2eff 100644 --- a/crates/trusted-server-adapter-spin/src/lib.rs +++ b/crates/trusted-server-adapter-spin/src/lib.rs @@ -13,5 +13,16 @@ 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 { + #[cfg(feature = "aps-runner-proxy-integration-test")] + 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..e0e797f0a 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,14 @@ async fn route(router: RouterService, req: Request) -> Response { router.oneshot(req).await.expect("should route request") } +#[cfg(feature = "aps-runner-proxy-integration-test")] +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 +75,73 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } +#[cfg(feature = "aps-runner-proxy-integration-test")] +#[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/v2", 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!( diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4cf2b7332..9fffe1b38 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -23,6 +23,8 @@ use crate::auction::types::{ MediaType, RENDER_DIMENSION_MAX, classify_aps_renderer_v1, record_auction_drop, }; use crate::error::TrustedServerError; +#[cfg(any(test, feature = "test-utils"))] +use crate::integrations::ensure_integration_backend_with_transport_timeouts; use crate::integrations::{ IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, UPSTREAM_RTB_MAX_RESPONSE_BYTES, collect_response_bounded, @@ -33,10 +35,18 @@ use crate::openrtb::{ UserExt, to_openrtb_i32, }; use crate::platform::{PlatformHttpRequest, PlatformResponse, RuntimeServices}; +#[cfg(any(test, feature = "test-utils"))] +use crate::platform::{ProxyHeaderEvidenceV1, RawProxyPolicyV1, RawProxyResponseV1}; use crate::settings::{IntegrationConfig, Settings}; const APS_INTEGRATION_ID: &str = "aps"; const APS_RENDERER_ROUTE: &str = "/integrations/aps/renderer"; +pub const APS_RENDERER_V1_ROUTE: &str = "/integrations/aps/renderer/v1"; +pub const APS_RUNNER_ROUTE: &str = "/integrations/aps/runner.js"; +pub const APS_RUNNER_UPSTREAM_URL: &str = + "https://client.aps.amazon-adsystem.com/prebid-creative.js"; +pub const APS_RUNNER_MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024; +pub const APS_RENDERER_SANDBOX: &str = "allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation"; const DEFAULT_CURRENCY: &str = "USD"; const APS_SDK_SOURCE: &str = "prebid"; const APS_SDK_VERSION: &str = "2.2.0"; @@ -48,8 +58,26 @@ const MAX_CREATIVE_URL_BYTES: usize = 4096; const MAX_LANGUAGE_BYTES: usize = 8; const MAX_PAGE_URL_BYTES: usize = 8192; const MAX_RENDER_ENVELOPE_BYTES: usize = 256 * 1024; +#[cfg(any(test, feature = "test-utils"))] +// Reserve downstream response/finalization overhead inside the externally +// observed five-second dispatch-to-final-byte ceiling. +const APS_RUNNER_TOTAL_TIMEOUT: Duration = Duration::from_millis(4_500); +#[cfg(any(test, feature = "test-utils"))] +/// Maximum wait for the APS runner response headers. +pub const APS_RUNNER_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(4); +#[cfg(any(test, feature = "test-utils"))] +/// Maximum duration of one blocking APS runner response-body read. +pub const APS_RUNNER_BLOCKING_READ_TIMEOUT: Duration = Duration::from_millis(250); const APS_RENDERER_CSP: &str = "default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; script-src 'unsafe-inline' https:; connect-src https:; frame-src https:; img-src https: data:; media-src https: blob:; style-src 'unsafe-inline' https:; font-src https: data:;"; +/// Whether `path` belongs to the reserved APS integration family. +#[must_use] +pub fn is_aps_family_path(path: &str) -> bool { + path == "/integrations/aps" || path.starts_with("/integrations/aps/") +} +#[cfg(any(test, feature = "test-utils"))] +const APS_RENDERER_V1_CSP: &str = "default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' 'self' https:; connect-src https:; frame-src https: data: blob:; img-src https: data: blob:; media-src https: data: blob:; style-src 'unsafe-inline' https:; font-src https: data:; worker-src https: blob:; form-action https:;"; + const APS_RENDERER_DOCUMENT: &str = concat!( r#" @@ -91,6 +119,119 @@ addEventListener('message',receive); "# ); +#[cfg(any(test, feature = "test-utils"))] +const APS_RENDERER_V1_DOCUMENT: &str = concat!( + r#" + + + +"# +); + /// Configuration for the APS `OpenRTB` integration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] #[validate(schema(function = "validate_inventory_identity_override"))] @@ -1265,6 +1406,280 @@ impl IntegrationProxy for ApsRendererIntegration { } } +#[cfg(any(test, feature = "test-utils"))] +#[derive(Debug)] +pub(crate) struct ApsV1Integration { + enabled: bool, +} + +#[cfg(any(test, feature = "test-utils"))] +impl ApsV1Integration { + fn mark_exact_headers(mut response: http::Response) -> http::Response { + response + .extensions_mut() + .insert(crate::platform::ExactResponseHeadersV1); + response + } + + pub(crate) fn from_settings(settings: &Settings) -> Result> { + Ok(Self { + enabled: settings + .integration_config::(APS_INTEGRATION_ID)? + .is_some(), + }) + } + + fn local_status( + status: StatusCode, + allow_get: bool, + ) -> Result, Report> { + let mut builder = http::Response::builder() + .status(status) + .header(header::CACHE_CONTROL, "no-store"); + if allow_get { + builder = builder.header(header::ALLOW, "GET"); + } + builder + .body(EdgeBody::empty()) + .change_context(TrustedServerError::Integration { + integration: APS_INTEGRATION_ID.to_string(), + message: "Failed to build local APS route response".to_string(), + }) + .map(Self::mark_exact_headers) + } + + fn renderer_response() -> Result, Report> { + http::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") + .header("x-content-type-options", "nosniff") + .header("referrer-policy", "no-referrer") + .header(header::CONTENT_SECURITY_POLICY, APS_RENDERER_V1_CSP) + .body(EdgeBody::from(APS_RENDERER_V1_DOCUMENT)) + .change_context(TrustedServerError::Integration { + integration: APS_INTEGRATION_ID.to_string(), + message: "Failed to build APS renderer v1 response".to_string(), + }) + .map(Self::mark_exact_headers) + } + + fn singleton_proxy_header( + evidence: &ProxyHeaderEvidenceV1, + required: bool, + ) -> Result, &'static str> { + match evidence { + ProxyHeaderEvidenceV1::Occurrences(values) => match values.as_slice() { + [] if !required => Ok(None), + [value] => Ok(Some(value.as_slice())), + [] => Err("missing_header"), + _ => Err("duplicate_header"), + }, + ProxyHeaderEvidenceV1::Combined(value) if !value.contains(&b',') => { + Ok(Some(value.as_slice())) + } + ProxyHeaderEvidenceV1::Combined(_) => Err("listed_header"), + ProxyHeaderEvidenceV1::Unavailable => Err("unavailable_header"), + } + } + + fn trim_http_ows(value: &[u8]) -> &[u8] { + let start = value + .iter() + .position(|byte| !matches!(byte, b' ' | b'\t')) + .unwrap_or(value.len()); + let end = value + .iter() + .rposition(|byte| !matches!(byte, b' ' | b'\t')) + .map_or(start, |index| index + 1); + &value[start..end] + } + + fn validate_runner_content_type(evidence: &ProxyHeaderEvidenceV1) -> Result<(), &'static str> { + let raw = Self::singleton_proxy_header(evidence, true)?; + let value = std::str::from_utf8(raw.ok_or("missing_content_type")?) + .map_err(|_| "invalid_content_type")?; + if !value.is_ascii() { + return Err("invalid_content_type"); + } + if value.as_bytes().contains(&b',') { + return Err("listed_content_type"); + } + + let mut parts = value.split(';'); + let essence = parts + .next() + .ok_or("missing_content_type")? + .trim_matches([' ', '\t']); + if !essence.eq_ignore_ascii_case("application/javascript") + && !essence.eq_ignore_ascii_case("text/javascript") + { + return Err("rejected_content_type"); + } + let Some(parameter) = parts.next() else { + return Ok(()); + }; + if parts.next().is_some() { + return Err("duplicate_content_type_parameter"); + } + let (name, value) = parameter + .split_once('=') + .ok_or("invalid_content_type_parameter")?; + if !name + .trim_matches([' ', '\t']) + .eq_ignore_ascii_case("charset") + || !value + .trim_matches([' ', '\t']) + .eq_ignore_ascii_case("utf-8") + { + return Err("rejected_content_type_parameter"); + } + Ok(()) + } + + fn validate_runner_content_encoding( + evidence: &ProxyHeaderEvidenceV1, + ) -> Result<(), &'static str> { + let Some(raw) = Self::singleton_proxy_header(evidence, false)? else { + return Ok(()); + }; + let value = Self::trim_http_ows(raw); + if value.contains(&b',') || !value.eq_ignore_ascii_case(b"identity") { + return Err("rejected_content_encoding"); + } + Ok(()) + } + + fn validate_runner_content_length( + evidence: &ProxyHeaderEvidenceV1, + ) -> Result, &'static str> { + let Some(value) = Self::singleton_proxy_header(evidence, false)? else { + return Ok(None); + }; + if value.is_empty() + || value.contains(&b',') + || !value.iter().all(u8::is_ascii_digit) + || (value.len() > 1 && value[0] == b'0') + { + return Err("invalid_content_length"); + } + let value = std::str::from_utf8(value) + .map_err(|_| "invalid_content_length")? + .parse::() + .map_err(|_| "invalid_content_length")?; + if value > APS_RUNNER_MAX_RESPONSE_BYTES { + return Err("content_length_overflow"); + } + Ok(Some(value)) + } + + fn validate_runner_response(response: &RawProxyResponseV1) -> Result<(), &'static str> { + if response.evidence.status != StatusCode::OK.as_u16() { + return Err("rejected_status"); + } + Self::validate_runner_content_type(&response.evidence.content_type)?; + Self::validate_runner_content_encoding(&response.evidence.content_encoding)?; + let declared_length = + Self::validate_runner_content_length(&response.evidence.content_length)?; + if response.body.len() > APS_RUNNER_MAX_RESPONSE_BYTES { + return Err("body_overflow"); + } + if declared_length.is_some_and(|length| length != response.body.len()) { + return Err("content_length_mismatch"); + } + std::str::from_utf8(&response.body).map_err(|_| "invalid_utf8")?; + Ok(()) + } + + async fn runner_response( + services: &RuntimeServices, + ) -> Result, &'static str> { + let outbound_request = http::Request::builder() + .method(Method::GET) + .uri(APS_RUNNER_UPSTREAM_URL) + .header(header::ACCEPT_ENCODING, "identity") + .body(EdgeBody::empty()) + .map_err(|_| "request_build_failed")?; + let backend = ensure_integration_backend_with_transport_timeouts( + services, + APS_RUNNER_UPSTREAM_URL, + APS_INTEGRATION_ID, + APS_RUNNER_FIRST_BYTE_TIMEOUT, + APS_RUNNER_BLOCKING_READ_TIMEOUT, + ) + .map_err(|_| "backend_unavailable")?; + let response = services + .http_client() + .send_raw_proxy_v1( + PlatformHttpRequest::new(outbound_request, backend), + RawProxyPolicyV1 { + total_timeout: APS_RUNNER_TOTAL_TIMEOUT, + first_byte_timeout: APS_RUNNER_FIRST_BYTE_TIMEOUT, + blocking_read_timeout: APS_RUNNER_BLOCKING_READ_TIMEOUT, + max_response_bytes: APS_RUNNER_MAX_RESPONSE_BYTES, + }, + ) + .await + .map_err(|_| "transport_failed")?; + Self::validate_runner_response(&response)?; + + http::Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header("cross-origin-resource-policy", "cross-origin") + .header("x-content-type-options", "nosniff") + .header("referrer-policy", "no-referrer") + .body(EdgeBody::from(response.body)) + .map(Self::mark_exact_headers) + .map_err(|_| "response_build_failed") + } +} + +#[cfg(any(test, feature = "test-utils"))] +#[async_trait(?Send)] +impl IntegrationProxy for ApsV1Integration { + fn integration_name(&self) -> &'static str { + APS_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + Vec::new() + } + + async fn handle( + &self, + _settings: &Settings, + services: &RuntimeServices, + request: http::Request, + ) -> Result, Report> { + let path = request.uri().path(); + if !path.starts_with("/integrations/aps/") { + return Self::local_status(StatusCode::NOT_FOUND, false); + } + if request.method() != Method::GET { + return Self::local_status(StatusCode::METHOD_NOT_ALLOWED, true); + } + if !self.enabled { + return Self::local_status(StatusCode::NOT_FOUND, false); + } + match path { + APS_RENDERER_V1_ROUTE => Self::renderer_response(), + APS_RUNNER_ROUTE => match Self::runner_response(services).await { + Ok(response) => Ok(response), + Err(reason) => { + log::warn!("APS runner proxy failed closed: {reason}"); + Self::local_status(StatusCode::BAD_GATEWAY, false) + } + }, + _ => Self::local_status(StatusCode::NOT_FOUND, false), + } + } +} + /// Register the APS static renderer endpoint when APS is enabled. /// /// # Errors @@ -1314,10 +1729,13 @@ mod tests { }; use crate::consent::ConsentContext; use crate::openrtb::{Eid, Uid}; - use crate::platform::GeoInfo; use crate::platform::test_support::{ StubHttpClient, build_services_with_http_client, noop_services, }; + use crate::platform::{ + GeoInfo, ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, RawProxyPolicyV1, + RawProxyResponseV1, + }; use crate::test_support::tests::create_test_settings; use serde_json::json; @@ -3061,4 +3479,466 @@ mod tests { assert!(APS_RENDERER_CSP.contains("sandbox allow-forms")); assert!(!APS_RENDERER_CSP.contains("allow-same-origin")); } + + #[test] + fn coordinated_cutover_routes_are_reserved_with_exact_local_method_policy() { + let enabled = ApsV1Integration { enabled: true }; + let disabled = ApsV1Integration { enabled: false }; + let settings = create_test_settings(); + let services = noop_services(); + + for path in [APS_RENDERER_V1_ROUTE, APS_RUNNER_ROUTE] { + let disabled_get = http::Request::builder() + .method(Method::GET) + .uri(path) + .body(EdgeBody::empty()) + .expect("should build disabled APS request"); + let response = + futures::executor::block_on(disabled.handle(&settings, &services, disabled_get)) + .expect("disabled APS family should answer locally"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + + for method in [ + Method::POST, + Method::HEAD, + Method::OPTIONS, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::TRACE, + Method::CONNECT, + Method::from_bytes(b"PROPFIND").expect("PROPFIND should be a valid method"), + ] { + let request = http::Request::builder() + .method(method.clone()) + .uri(path) + .body(EdgeBody::empty()) + .expect("should build APS method rejection"); + let response = + futures::executor::block_on(enabled.handle(&settings, &services, request)) + .expect("reserved APS family should reject unsupported methods locally"); + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(response.headers()[header::ALLOW], "GET"); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert_eq!(response.headers().len(), 2, "method={method} path={path}"); + assert!( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .is_empty() + ); + } + } + + for path in [ + "/integrations/aps/renderer/v2", + "/integrations/aps/runner/v1.js", + "/integrations/aps/renderer/v1/extra", + "/integrations/aps/not-a-route", + ] { + let request = http::Request::builder() + .method(Method::GET) + .uri(path) + .body(EdgeBody::empty()) + .expect("should build unknown APS family request"); + let response = + futures::executor::block_on(enabled.handle(&settings, &services, request)) + .expect("unknown APS family path should answer locally"); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "path={path}"); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + } + } + + #[test] + fn aps_family_classifier_has_an_exact_segment_boundary() { + assert!(is_aps_family_path("/integrations/aps")); + assert!(is_aps_family_path("/integrations/aps/renderer/v1")); + assert!(!is_aps_family_path("/integrations/apsx")); + assert!(!is_aps_family_path("/integrations/ap")); + } + + #[test] + fn coordinated_cutover_renderer_has_exact_immutable_embedding_policy() { + let integration = ApsV1Integration { enabled: true }; + let request = http::Request::builder() + .method(Method::GET) + .uri(APS_RENDERER_V1_ROUTE) + .body(EdgeBody::empty()) + .expect("should build versioned renderer request"); + let response = futures::executor::block_on(integration.handle( + &create_test_settings(), + &noop_services(), + request, + )) + .expect("versioned renderer should be served"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "public, max-age=31536000, immutable" + ); + assert_eq!(response.headers()["x-content-type-options"], "nosniff"); + assert_eq!(response.headers()["referrer-policy"], "no-referrer"); + assert_eq!( + response.headers()[header::CONTENT_SECURITY_POLICY], + APS_RENDERER_V1_CSP + ); + assert!(!response.headers().contains_key("x-frame-options")); + assert!(!APS_RENDERER_V1_CSP.contains("frame-ancestors")); + assert_eq!( + APS_RENDERER_SANDBOX, + "allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation" + ); + + let body = futures::executor::block_on( + response + .into_body() + .into_bytes_bounded(APS_RUNNER_MAX_RESPONSE_BYTES), + ) + .expect("renderer body should stay within the runner cap"); + assert_eq!(body.as_ref(), APS_RENDERER_V1_DOCUMENT.as_bytes()); + assert!(APS_RENDERER_V1_DOCUMENT.contains("/integrations/aps/runner.js")); + assert!(!APS_RENDERER_V1_DOCUMENT.contains("client.aps.amazon-adsystem.com")); + } + + fn raw_runner_response( + body: impl Into>, + content_type: ProxyHeaderEvidenceV1, + content_encoding: ProxyHeaderEvidenceV1, + content_length: ProxyHeaderEvidenceV1, + ) -> RawProxyResponseV1 { + RawProxyResponseV1 { + evidence: ProxyResponseEvidenceV1 { + status: 200, + content_type, + content_encoding, + content_length, + }, + body: body.into(), + } + } + + fn request_runner( + stub: &Arc, + ) -> Result, Report> { + let services = build_services_with_http_client( + Arc::clone(stub) as Arc + ); + let request = http::Request::builder() + .method(Method::GET) + .uri(APS_RUNNER_ROUTE) + .body(EdgeBody::empty()) + .expect("should build APS runner request"); + futures::executor::block_on(ApsV1Integration { enabled: true }.handle( + &create_test_settings(), + &services, + request, + )) + } + + #[test] + fn coordinated_cutover_runner_proxies_exact_valid_identity_bytes() { + let body = b"window.fictionalApsRunner = true;".to_vec(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_raw_proxy_response(raw_runner_response( + body.clone(), + ProxyHeaderEvidenceV1::one("application/javascript; charset=utf-8"), + ProxyHeaderEvidenceV1::absent(), + ProxyHeaderEvidenceV1::one(body.len().to_string()), + )); + + let response = request_runner(&stub).expect("valid runner should be proxied"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "application/javascript; charset=utf-8" + ); + assert_eq!(response.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*"); + assert_eq!( + response.headers()["cross-origin-resource-policy"], + "cross-origin" + ); + assert_eq!(response.headers()["x-content-type-options"], "nosniff"); + assert_eq!(response.headers()["referrer-policy"], "no-referrer"); + assert_eq!(response.headers().len(), 5); + let returned = futures::executor::block_on( + response + .into_body() + .into_bytes_bounded(APS_RUNNER_MAX_RESPONSE_BYTES), + ) + .expect("valid runner body should remain bounded"); + assert_eq!(returned.as_ref(), body.as_slice()); + + assert_eq!(stub.recorded_backend_names(), vec!["stub-backend"]); + assert_eq!(stub.recorded_request_uris(), vec![APS_RUNNER_UPSTREAM_URL]); + assert_eq!(stub.recorded_request_methods(), vec!["GET"]); + assert_eq!( + stub.recorded_request_headers(), + vec![vec![( + "accept-encoding".to_string(), + "identity".to_string() + )]] + ); + assert_eq!(stub.recorded_request_bodies(), vec![Vec::::new()]); + assert_eq!( + stub.recorded_raw_proxy_policies(), + vec![RawProxyPolicyV1 { + total_timeout: Duration::from_millis(4_500), + first_byte_timeout: Duration::from_secs(4), + blocking_read_timeout: Duration::from_millis(250), + max_response_bytes: APS_RUNNER_MAX_RESPONSE_BYTES, + }] + ); + } + + #[test] + fn coordinated_cutover_runner_rejects_ambiguous_or_invalid_upstream_evidence() { + let valid_body = b"window.fictionalApsRunner = true;".to_vec(); + let valid_length = valid_body.len().to_string(); + let cases = [ + ( + "redirect", + RawProxyResponseV1 { + evidence: ProxyResponseEvidenceV1 { + status: 302, + content_type: ProxyHeaderEvidenceV1::one("application/javascript"), + content_encoding: ProxyHeaderEvidenceV1::absent(), + content_length: ProxyHeaderEvidenceV1::one(valid_length.clone()), + }, + body: valid_body.clone(), + }, + ), + ( + "missing content type", + raw_runner_response( + valid_body.clone(), + ProxyHeaderEvidenceV1::absent(), + ProxyHeaderEvidenceV1::absent(), + ProxyHeaderEvidenceV1::one(valid_length.clone()), + ), + ), + ( + "duplicate content type", + raw_runner_response( + valid_body.clone(), + ProxyHeaderEvidenceV1::Occurrences(vec![ + b"application/javascript".to_vec(), + b"text/javascript".to_vec(), + ]), + ProxyHeaderEvidenceV1::absent(), + ProxyHeaderEvidenceV1::one(valid_length.clone()), + ), + ), + ( + "combined content type list", + raw_runner_response( + valid_body.clone(), + ProxyHeaderEvidenceV1::Combined( + b"application/javascript, text/javascript".to_vec(), + ), + ProxyHeaderEvidenceV1::absent(), + ProxyHeaderEvidenceV1::one(valid_length.clone()), + ), + ), + ( + "wrong charset", + raw_runner_response( + valid_body.clone(), + ProxyHeaderEvidenceV1::one("application/javascript; charset=iso-8859-1"), + ProxyHeaderEvidenceV1::absent(), + ProxyHeaderEvidenceV1::one(valid_length.clone()), + ), + ), + ( + "encoded body", + raw_runner_response( + valid_body.clone(), + ProxyHeaderEvidenceV1::one("application/javascript"), + ProxyHeaderEvidenceV1::one("gzip"), + ProxyHeaderEvidenceV1::one(valid_length.clone()), + ), + ), + ( + "unavailable encoding evidence", + raw_runner_response( + valid_body.clone(), + ProxyHeaderEvidenceV1::one("application/javascript"), + ProxyHeaderEvidenceV1::Unavailable, + ProxyHeaderEvidenceV1::one(valid_length.clone()), + ), + ), + ( + "leading-zero length", + raw_runner_response( + valid_body.clone(), + ProxyHeaderEvidenceV1::one("application/javascript"), + ProxyHeaderEvidenceV1::absent(), + ProxyHeaderEvidenceV1::one(format!("0{valid_length}")), + ), + ), + ( + "mismatched length", + raw_runner_response( + valid_body.clone(), + ProxyHeaderEvidenceV1::one("application/javascript"), + ProxyHeaderEvidenceV1::absent(), + ProxyHeaderEvidenceV1::one("1"), + ), + ), + ( + "invalid utf-8", + raw_runner_response( + vec![0xff], + ProxyHeaderEvidenceV1::one("application/javascript"), + ProxyHeaderEvidenceV1::absent(), + ProxyHeaderEvidenceV1::one("1"), + ), + ), + ]; + + for (name, response) in cases { + let stub = Arc::new(StubHttpClient::new()); + stub.push_raw_proxy_response(response); + let response = request_runner(&stub).expect("invalid runner should fail locally"); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY, "case={name}"); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert_eq!(response.headers().len(), 1, "case={name}"); + let body = futures::executor::block_on( + response + .into_body() + .into_bytes_bounded(APS_RUNNER_MAX_RESPONSE_BYTES), + ) + .expect("local failure body should be bounded"); + assert!(body.is_empty(), "case={name}"); + } + } + + #[test] + fn coordinated_cutover_runner_header_grammars_are_closed_and_complete() { + for content_type in [ + ProxyHeaderEvidenceV1::one("application/javascript"), + ProxyHeaderEvidenceV1::one("text/javascript"), + ProxyHeaderEvidenceV1::one(" Application/JavaScript ; Charset = UTF-8 "), + ProxyHeaderEvidenceV1::Combined(b"text/javascript; charset=utf-8".to_vec()), + ] { + assert!( + ApsV1Integration::validate_runner_content_type(&content_type).is_ok(), + "accepted content type: {content_type:?}" + ); + } + for content_type in [ + ProxyHeaderEvidenceV1::Unavailable, + ProxyHeaderEvidenceV1::absent(), + ProxyHeaderEvidenceV1::one("application/ecmascript"), + ProxyHeaderEvidenceV1::one("application/javascript; charset=\"utf-8\""), + ProxyHeaderEvidenceV1::one("application/javascript; charset=utf-8; level=1"), + ProxyHeaderEvidenceV1::one("application/javascript; boundary=x"), + ProxyHeaderEvidenceV1::one("application/javascript,"), + ProxyHeaderEvidenceV1::one("application/javascript\u{a0}"), + ] { + assert!( + ApsV1Integration::validate_runner_content_type(&content_type).is_err(), + "rejected content type: {content_type:?}" + ); + } + + for encoding in [ + ProxyHeaderEvidenceV1::absent(), + ProxyHeaderEvidenceV1::one("identity"), + ProxyHeaderEvidenceV1::Combined(b" IDENTITY\t".to_vec()), + ] { + assert!( + ApsV1Integration::validate_runner_content_encoding(&encoding).is_ok(), + "accepted encoding: {encoding:?}" + ); + } + for encoding in [ + ProxyHeaderEvidenceV1::Unavailable, + ProxyHeaderEvidenceV1::one(""), + ProxyHeaderEvidenceV1::one("gzip"), + ProxyHeaderEvidenceV1::one("identity, identity"), + ProxyHeaderEvidenceV1::Occurrences(vec![b"identity".to_vec(), b"identity".to_vec()]), + ] { + assert!( + ApsV1Integration::validate_runner_content_encoding(&encoding).is_err(), + "rejected encoding: {encoding:?}" + ); + } + + assert_eq!( + ApsV1Integration::validate_runner_content_length(&ProxyHeaderEvidenceV1::absent()), + Ok(None) + ); + assert_eq!( + ApsV1Integration::validate_runner_content_length(&ProxyHeaderEvidenceV1::one("0")), + Ok(Some(0)) + ); + assert_eq!( + ApsV1Integration::validate_runner_content_length(&ProxyHeaderEvidenceV1::Combined( + APS_RUNNER_MAX_RESPONSE_BYTES.to_string().into_bytes() + )), + Ok(Some(APS_RUNNER_MAX_RESPONSE_BYTES)) + ); + for length in [ + ProxyHeaderEvidenceV1::Unavailable, + ProxyHeaderEvidenceV1::one(""), + ProxyHeaderEvidenceV1::one("00"), + ProxyHeaderEvidenceV1::one("01"), + ProxyHeaderEvidenceV1::one("+1"), + ProxyHeaderEvidenceV1::one(" 1"), + ProxyHeaderEvidenceV1::one("1 "), + ProxyHeaderEvidenceV1::one("1, 1"), + ProxyHeaderEvidenceV1::one((APS_RUNNER_MAX_RESPONSE_BYTES + 1).to_string()), + ProxyHeaderEvidenceV1::Occurrences(vec![b"1".to_vec(), b"1".to_vec()]), + ] { + assert!( + ApsV1Integration::validate_runner_content_length(&length).is_err(), + "rejected length: {length:?}" + ); + } + } + + #[test] + fn coordinated_cutover_runner_accepts_exact_cap_and_rejects_one_byte_over() { + let exact = raw_runner_response( + vec![b' '; APS_RUNNER_MAX_RESPONSE_BYTES], + ProxyHeaderEvidenceV1::one("application/javascript"), + ProxyHeaderEvidenceV1::absent(), + ProxyHeaderEvidenceV1::absent(), + ); + assert!(ApsV1Integration::validate_runner_response(&exact).is_ok()); + + let over = raw_runner_response( + vec![b' '; APS_RUNNER_MAX_RESPONSE_BYTES + 1], + ProxyHeaderEvidenceV1::one("application/javascript"), + ProxyHeaderEvidenceV1::absent(), + ProxyHeaderEvidenceV1::absent(), + ); + assert_eq!( + ApsV1Integration::validate_runner_response(&over), + Err("body_overflow") + ); + } + + #[test] + fn coordinated_cutover_runner_transport_failure_is_empty_and_non_leaking() { + let stub = Arc::new(StubHttpClient::new()); + let response = request_runner(&stub).expect("transport failure should answer locally"); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert_eq!(response.headers().len(), 1); + let body = futures::executor::block_on( + response + .into_body() + .into_bytes_bounded(APS_RUNNER_MAX_RESPONSE_BYTES), + ) + .expect("local failure body should be bounded"); + assert!(body.is_empty()); + } } diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 90d688693..ad6008bd0 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -99,6 +99,32 @@ pub(crate) fn ensure_integration_backend_with_timeout( }) } +/// Registers or retrieves a platform backend with separate response-header and +/// response-body gap timeouts. +/// +/// # Errors +/// +/// Returns an error when `url` cannot be parsed, is missing a host, or the +/// backend registration fails. +#[cfg(any(test, feature = "test-utils"))] +pub(crate) fn ensure_integration_backend_with_transport_timeouts( + services: &RuntimeServices, + url: &str, + integration: &'static str, + first_byte_timeout: Duration, + between_bytes_timeout: Duration, +) -> Result> { + let mut spec = integration_backend_spec(url, integration, true, first_byte_timeout)?; + spec.between_bytes_timeout = between_bytes_timeout; + services + .backend() + .ensure(&spec) + .change_context(TrustedServerError::Integration { + integration: integration.to_string(), + message: "Failed to register backend".to_string(), + }) +} + /// Compute the deterministic platform backend name for a URL without registering it. /// /// Parses `url`, builds a [`PlatformBackendSpec`], and delegates to diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 291a54242..c1d04aab7 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -693,6 +693,7 @@ struct IntegrationRegistryInner { patch_router: Router, head_router: Router, options_router: Router, + reserved_proxies: Vec<(&'static str, Arc)>, // Metadata for introspection routes: Vec<(IntegrationEndpoint, &'static str)>, @@ -716,6 +717,7 @@ impl Default for IntegrationRegistryInner { patch_router: Router::new(), head_router: Router::new(), options_router: Router::new(), + reserved_proxies: Vec::new(), routes: Vec::new(), enabled_integration_ids: Vec::new(), deferred_js_ids: Vec::new(), @@ -867,6 +869,62 @@ impl IntegrationRegistry { }) } + /// Build the coordinated-cutover APS surface for hermetic tests only. + /// + /// Ordinary production construction deliberately has no reserved APS + /// dispatcher until the hard cutover task activates it. + /// + /// # Errors + /// + /// Returns an error when ordinary registry construction or APS + /// configuration validation fails. + #[cfg(any(test, feature = "test-utils"))] + pub fn new_with_aps_v1_for_tests( + settings: &Settings, + ) -> Result> { + let mut registry = Self::new(settings)?; + let proxy: Arc = + Arc::new(super::aps::ApsV1Integration::from_settings(settings)?); + let inner = Arc::get_mut(&mut registry.inner).ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "APS test registry became shared during construction".to_string(), + }) + })?; + inner.reserved_proxies.push(("/integrations/aps", proxy)); + Ok(registry) + } + + fn reserved_proxy(&self, path: &str) -> Option<&Arc> { + self.inner + .reserved_proxies + .iter() + .find(|(family, _)| { + path == *family + || path + .strip_prefix(*family) + .is_some_and(|suffix| suffix.starts_with('/')) + }) + .map(|(_, proxy)| proxy) + } + + /// Return true when a coordinated-cutover family owns this path. + #[must_use] + pub fn has_reserved_path(&self, path: &str) -> bool { + self.reserved_proxy(path).is_some() + } + + /// Dispatch a coordinated-cutover family before auth, EC, filters, and fallback. + #[must_use] + pub async fn handle_reserved_proxy( + &self, + settings: &Settings, + services: &RuntimeServices, + req: Request, + ) -> Option, Report>> { + let proxy = self.reserved_proxy(req.uri().path())?; + Some(proxy.handle(settings, services, req).await) + } + fn find_route(&self, method: &Method, path: &str) -> Option<&RouteValue> { let router = match *method { Method::GET => &self.inner.get_router, @@ -1181,6 +1239,7 @@ impl IntegrationRegistry { patch_router: Router::new(), head_router: Router::new(), options_router: Router::new(), + reserved_proxies: Vec::new(), routes: Vec::new(), enabled_integration_ids: Vec::new(), html_rewriters: attribute_rewriters, @@ -1210,6 +1269,7 @@ impl IntegrationRegistry { patch_router: Router::new(), head_router: Router::new(), options_router: Router::new(), + reserved_proxies: Vec::new(), routes: Vec::new(), enabled_integration_ids: Vec::new(), html_rewriters: attribute_rewriters, @@ -1235,6 +1295,7 @@ impl IntegrationRegistry { patch_router: Router::new(), head_router: Router::new(), options_router: Router::new(), + reserved_proxies: Vec::new(), routes: Vec::new(), enabled_integration_ids: Vec::new(), html_rewriters: Vec::new(), @@ -1300,6 +1361,7 @@ impl IntegrationRegistry { patch_router, head_router, options_router, + reserved_proxies: Vec::new(), routes: Vec::new(), enabled_integration_ids: Vec::new(), html_rewriters: Vec::new(), @@ -1462,6 +1524,38 @@ mod tests { ); } + #[test] + fn aps_coordinated_cutover_family_exists_only_in_explicit_test_registry() { + let settings = create_test_settings(); + let ordinary = IntegrationRegistry::new(&settings).expect("ordinary registry should build"); + assert!(!ordinary.has_reserved_path("/integrations/aps")); + assert!(!ordinary.has_reserved_path("/integrations/aps/runner.js")); + + let cutover = IntegrationRegistry::new_with_aps_v1_for_tests(&settings) + .expect("coordinated-cutover registry should build"); + assert!(cutover.has_reserved_path("/integrations/aps")); + assert!(cutover.has_reserved_path("/integrations/aps/runner.js")); + assert!(cutover.has_reserved_path("/integrations/aps/malformed/path")); + assert!(!cutover.has_reserved_path("/integrations/apsx/runner.js")); + assert!(!cutover.has_reserved_path("/integrations/aps-legacy")); + + let request = Request::builder() + .method(Method::GET) + .uri("/integrations/aps/renderer/v1") + .header(HEADER_X_TS_EC.clone(), "caller-controlled") + .body(EdgeBody::empty()) + .expect("should build reserved APS request"); + let response = futures::executor::block_on(cutover.handle_reserved_proxy( + &settings, + &noop_services(), + request, + )) + .expect("reserved family should be handled") + .expect("disabled APS response should be local"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + } + #[test] fn filter_request_applies_request_headers_and_returns_response_headers() { let registry = diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index c93cd757d..c1c3711ed 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -1,5 +1,6 @@ use std::any::Any; use std::fmt; +use std::time::Duration; use edgezero_core::http::{Request as EdgeRequest, Response as EdgeResponse}; use error_stack::Report; @@ -7,6 +8,64 @@ use error_stack::Report; use super::PlatformError; use super::image_optimizer::PlatformImageOptimizerOptions; +/// Raw evidence for one security-relevant upstream response header. +/// +/// Values are never split or normalized. A runtime that preserves duplicate +/// fields returns every occurrence; a runtime that visibly combines fields +/// returns that exact combined byte string; erased evidence is unavailable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProxyHeaderEvidenceV1 { + /// Every raw header occurrence, including an empty vector for known absence. + Occurrences(Vec>), + /// One exact runtime-combined value. Core must not split it. + Combined(Vec), + /// The runtime erased or ambiguously transformed the evidence. + Unavailable, +} + +impl ProxyHeaderEvidenceV1 { + /// Known absence of the header. + #[must_use] + pub fn absent() -> Self { + Self::Occurrences(Vec::new()) + } + + /// One preserved raw header occurrence. + #[must_use] + pub fn one(value: impl Into>) -> Self { + Self::Occurrences(vec![value.into()]) + } +} + +/// Status and raw security-header evidence captured before adapter normalization. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyResponseEvidenceV1 { + pub status: u16, + pub content_type: ProxyHeaderEvidenceV1, + pub content_encoding: ProxyHeaderEvidenceV1, + pub content_length: ProxyHeaderEvidenceV1, +} + +/// Dedicated bounded raw-proxy transport policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RawProxyPolicyV1 { + /// Monotonic deadline covering dispatch through the final body byte. + pub total_timeout: Duration, + /// Maximum time the transport may wait for response headers. + pub first_byte_timeout: Duration, + /// Maximum duration of one blocking response-body read. + pub blocking_read_timeout: Duration, + /// Maximum accepted identity-body bytes. + pub max_response_bytes: usize, +} + +/// Byte-preserving response produced by the dedicated raw-proxy transport. +#[derive(Debug)] +pub struct RawProxyResponseV1 { + pub evidence: ProxyResponseEvidenceV1, + pub body: Vec, +} + /// Outbound HTTP request paired with a pre-resolved backend name. /// /// Uses `EdgeZero`'s neutral [`EdgeRequest`] type so adapters share one @@ -266,6 +325,21 @@ pub trait PlatformHttpClient: Send + Sync { request: PlatformHttpRequest, ) -> Result>; + /// Send one response-evidence-preserving, byte-bounded raw proxy request. + /// + /// Adapters must disable redirects and transformations, capture the three + /// evidence headers before generic normalization, enforce the total + /// monotonic deadline and byte cap, and cancel/drop in-flight resources on + /// failure. The default fails closed for platforms without that contract. + async fn send_raw_proxy_v1( + &self, + _request: PlatformHttpRequest, + _policy: RawProxyPolicyV1, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported) + .attach("bounded raw-proxy transport is unavailable on this platform")) + } + /// Start an upstream request without waiting for it to complete. /// /// # Errors diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 287f1accf..6a6227841 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -45,7 +45,8 @@ pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformK pub use error::PlatformError; pub use http::{ PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, - PlatformSelectResult, UnavailableHttpClient, + PlatformSelectResult, ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, RawProxyPolicyV1, + RawProxyResponseV1, UnavailableHttpClient, }; pub use image_optimizer::{ PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, @@ -53,6 +54,8 @@ pub use image_optimizer::{ }; pub use kv::UnavailableKvStore; pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; +#[cfg(any(test, feature = "test-utils"))] +pub use types::ExactResponseHeadersV1; pub use types::{ ClientInfo, GeoInfo, PlatformBackendSpec, RuntimeServices, RuntimeServicesBuilder, StoreId, StoreName, diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 10389c787..250f36bde 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -11,7 +11,8 @@ use super::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, - PlatformSelectResult, RuntimeServices, StoreId, StoreName, + PlatformSelectResult, RawProxyPolicyV1, RawProxyResponseV1, RuntimeServices, StoreId, + StoreName, }; use crate::request_signing::{JWKS_STORE_NAME, SIGNING_STORE_NAME}; @@ -234,6 +235,8 @@ pub(crate) struct StubHttpClient { request_uris: Mutex>, // Outgoing request bodies captured per send call, collected to bytes. request_bodies: Mutex>>, + raw_proxy_responses: Mutex>, + raw_proxy_policies: Mutex>, } struct StubHttpResponse { @@ -257,6 +260,8 @@ impl StubHttpClient { request_methods: Mutex::new(Vec::new()), request_uris: Mutex::new(Vec::new()), request_bodies: Mutex::new(Vec::new()), + raw_proxy_responses: Mutex::new(VecDeque::new()), + raw_proxy_policies: Mutex::new(Vec::new()), } } @@ -298,6 +303,22 @@ impl StubHttpClient { }); } + /// Queue one response for the dedicated raw-proxy transport boundary. + pub fn push_raw_proxy_response(&self, response: RawProxyResponseV1) { + self.raw_proxy_responses + .lock() + .expect("should lock raw proxy responses") + .push_back(response); + } + + /// Return raw-proxy policies captured per dedicated send. + pub fn recorded_raw_proxy_policies(&self) -> Vec { + self.raw_proxy_policies + .lock() + .expect("should lock raw proxy policies") + .clone() + } + /// Inject a `select()` error: the next call to `select()` will return /// `ready: Err(...)` with the failed request's backend name in /// `failed_backend_name`. The corresponding queued response is consumed. @@ -467,6 +488,77 @@ impl PlatformHttpClient for StubHttpClient { Ok(PlatformResponse::new(edge_response)) } + async fn send_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + if request.image_optimizer.is_some() || request.stream_response { + return Err(Report::new(PlatformError::HttpClient) + .attach("unsupported option on StubHttpClient raw proxy request")); + } + + self.calls + .lock() + .expect("should lock calls") + .push(request.backend_name.clone()); + self.raw_proxy_policies + .lock() + .expect("should lock raw proxy policies") + .push(policy); + self.cache_bypass_flags + .lock() + .expect("should lock cache bypass flags") + .push(request.bypass_cache); + self.request_methods + .lock() + .expect("should lock request methods") + .push(request.request.method().to_string()); + self.request_uris + .lock() + .expect("should lock request URIs") + .push(request.request.uri().to_string()); + self.request_headers + .lock() + .expect("should lock request headers") + .push( + request + .request + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + String::from_utf8_lossy(value.as_bytes()).into_owned(), + ) + }) + .collect(), + ); + + let (_, body) = request.request.into_parts(); + let body = body + .into_bytes_bounded(MAX_RECORDED_BODY_BYTES) + .await + .change_context(PlatformError::HttpClient)? + .to_vec(); + self.request_bodies + .lock() + .expect("should lock request bodies") + .push(body); + + let response = self + .raw_proxy_responses + .lock() + .expect("should lock raw proxy responses") + .pop_front() + .ok_or_else(|| Report::new(PlatformError::HttpClient))?; + if response.body.len() > policy.max_response_bytes { + return Err(Report::new(PlatformError::HttpClient) + .attach("stub raw proxy body exceeds configured cap")); + } + Ok(response) + } + async fn send_async( &self, request: PlatformHttpRequest, diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index a39a26430..307fec018 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -10,6 +10,16 @@ use super::{ PlatformSecretStore, }; +/// Response-extension marker for routes whose security contract owns the +/// complete application-header set. +/// +/// Adapter finalizers must not append geo, deployment, or operator-configured +/// headers to a marked response. HTTP runtimes may still add transport framing +/// such as `Content-Length`. +#[cfg(any(test, feature = "test-utils"))] +#[derive(Debug, Clone, Copy, Default)] +pub struct ExactResponseHeadersV1; + /// Geographic information extracted from a request. /// /// Serde derives are required because `GeoInfo` is embedded in diff --git a/crates/trusted-server-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 = + "`, + }), + ); + await page.goto(runtimeUrl("/aps-v1-protocol-test")); + + const makeDescriptor = (bidId: string) => { + const value = descriptor("iframe"); + value.bidId = bidId; + const envelope = JSON.parse( + Buffer.from(value.aaxResponse, "base64").toString("utf8"), + ) as { seatbid: Array<{ bid: Array<{ id: string }> }> }; + envelope.seatbid[0].bid[0].id = bidId; + value.aaxResponse = Buffer.from( + JSON.stringify(envelope), + "utf8", + ).toString("base64"); + return value; + }; + const start = async ( + slotId: string, + bidId: string, + rendererOverrides: Record = {}, + ) => { + const nonce = `n1_${slotId.padEnd(22, "x").slice(0, 22)}`; + await page.evaluate( + ({ slotId, nonce, renderer }) => { + ( + window as unknown as { + startApsV1(options: Record): void; + } + ).startApsV1({ slotId, nonce, renderer }); + }, + { + slotId, + nonce, + renderer: { + ...makeDescriptor(bidId), + ...rendererOverrides, + }, + }, + ); + return nonce; + }; + const messages = (slotId: string) => + page.evaluate( + (id) => + ( + window as unknown as { + apsV1Records: Record< + string, + { messages: Array> } + >; + } + ).apsV1Records[id]?.messages ?? [], + slotId, + ); + + await start("duplicate-success", "duplicate-success-bid"); + await expect + .poll(async () => + (await messages("duplicate-success")).map( + (message) => message.message, + ), + ) + .toEqual([ + "TS APS Document Accepted", + "TS APS Runner Loaded", + "TS APS Render Completed", + ]); + await expect(page.locator("#duplicate-success .existing")).toHaveCount( + 0, + ); + expect( + (await messages("duplicate-success")).filter((message) => + String(message.message).includes("Render "), + ), + ).toHaveLength(1); + + await start("reject-case", "reject-case-bid"); + await expect + .poll(async () => await messages("reject-case")) + .toContainEqual( + expect.objectContaining({ + message: "TS APS Render Failed", + reason: "runner_failed", + }), + ); + await expect(page.locator("#reject-case .existing")).toHaveCount(1); + + await start("silent-case", "silent-case-bid"); + await expect + .poll(async () => + (await messages("silent-case")).map( + (message) => message.message, + ), + ) + .toEqual(["TS APS Document Accepted", "TS APS Runner Loaded"]); + await page.waitForTimeout(150); + expect(await messages("silent-case")).toHaveLength(2); + + await start("nested-case", "nested-case-bid"); + await expect + .poll(async () => await messages("nested-case")) + .toContainEqual( + expect.objectContaining({ + message: "TS APS Render Completed", + }), + ); + + const requestsBeforeInvalid = runnerRequests; + await start("invalid-case", "invalid-case-bid", { + unexpected: true, + }); + await expect + .poll(async () => await messages("invalid-case")) + .toContainEqual( + expect.objectContaining({ + message: "TS APS Render Failed", + reason: "descriptor_invalid", + }), + ); + expect(runnerRequests).toBe(requestsBeforeInvalid); + + await page.unroute(runtimeUrl("/integrations/aps/runner.js")); + await page.route(runtimeUrl("/integrations/aps/runner.js"), (route) => + route.abort(), + ); + await start("load-failure-case", "load-failure-case-bid"); + await expect + .poll(async () => await messages("load-failure-case")) + .toContainEqual( + expect.objectContaining({ + message: "TS APS Render Failed", + reason: "runner_no_load", + }), + ); + }); +}); 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/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/tests/aps_runner_proxy.rs b/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs new file mode 100644 index 000000000..feb2e5823 --- /dev/null +++ b/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs @@ -0,0 +1,491 @@ +#![allow(dead_code, unused_imports)] + +mod common; +mod environments; + +use common::aps_runner_upstream::{ApsRunnerUpstream, FictionalResponse, ResponseWrite}; +use common::runtime::{RuntimeEnvironment, wasm_binary_path}; +use environments::spin::SpinRuntime; +use environments::{axum::AxumDevServer, cloudflare::CloudflareWorkers, fastly::FastlyViceroy}; +use reqwest::blocking::{Client, Response}; +use std::collections::BTreeSet; +use std::time::{Duration, Instant}; +use trusted_server_core::integrations::aps::{ + APS_RUNNER_MAX_RESPONSE_BYTES, APS_RUNNER_ROUTE, APS_RUNNER_UPSTREAM_URL, +}; + +const SUCCESS_HEADERS: [&str; 5] = [ + "access-control-allow-origin", + "content-type", + "cross-origin-resource-policy", + "referrer-policy", + "x-content-type-options", +]; + +struct CorpusCase { + name: &'static str, + upstream: FictionalResponse, + expected_status: u16, + expected_body: Option>, + maximum_elapsed: Option, +} + +impl CorpusCase { + fn success(name: &'static str, upstream: FictionalResponse, body: Vec) -> Self { + Self { + name, + upstream, + expected_status: 200, + expected_body: Some(body), + maximum_elapsed: None, + } + } + + fn failure(name: &'static str, upstream: FictionalResponse) -> Self { + Self { + name, + upstream, + expected_status: 502, + expected_body: Some(Vec::new()), + maximum_elapsed: None, + } + } + + fn deadline(name: &'static str, upstream: FictionalResponse) -> Self { + Self { + maximum_elapsed: Some(Duration::from_secs(5)), + ..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, stricter five-second elapsed assertion. + // 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..f3e0cea29 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") @@ -758,6 +765,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/docs/guide/error-reference.md b/docs/guide/error-reference.md index b5348ed9f..3bea7cd79 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -621,7 +621,7 @@ Warning: viceroy version mismatch **Solution:** ```bash -cargo install viceroy --version 0.17.0 --locked --force +cargo install viceroy --version 0.19.0 --locked --force ``` --- diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 71291eb1e..d7fe51828 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -50,7 +50,7 @@ Simulates the full Fastly production environment locally. Install and configure the Fastly CLI using the [Fastly setup guide](/guide/fastly), then install Viceroy: ```bash -cargo install viceroy --version 0.17.0 --locked --force +cargo install viceroy --version 0.19.0 --locked --force ``` Start the local Fastly simulator: diff --git a/docs/guide/testing.md b/docs/guide/testing.md index e1751bc49..d8258948a 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -10,7 +10,7 @@ Viceroy is the local test runtime for Fastly Compute applications. It simulates ```bash # Install viceroy -cargo install viceroy --version 0.17.0 --locked --force +cargo install viceroy --version 0.19.0 --locked --force # Run Fastly/WASM crate tests (viceroy is invoked automatically via .cargo/config.toml runner) cargo test-fastly diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index 05c88d6cf..3240b64b6 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -531,31 +531,46 @@ Every task's regression suite therefore remains green in task order. **Files:** - Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Modify: `crates/trusted-server-core/src/integrations/mod.rs` - Modify: `crates/trusted-server-core/src/integrations/registry.rs` - Modify: `crates/trusted-server-core/src/platform/http.rs` +- Modify: `crates/trusted-server-core/src/platform/mod.rs` - Modify: `crates/trusted-server-core/src/platform/test_support.rs` - Modify: `crates/trusted-server-core/src/platform/types.rs` - Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/middleware.rs` - Modify: `crates/trusted-server-adapter-fastly/src/platform.rs` - Modify: `crates/trusted-server-adapter-fastly/Cargo.toml` - Modify: `crates/trusted-server-adapter-axum/src/app.rs` +- Modify: `crates/trusted-server-adapter-axum/src/main.rs` +- Modify: `crates/trusted-server-adapter-axum/src/middleware.rs` - Modify: `crates/trusted-server-adapter-axum/src/platform.rs` - Modify: `crates/trusted-server-adapter-axum/tests/routes.rs` - Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/src/lib.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/src/middleware.rs` - Modify: `crates/trusted-server-adapter-cloudflare/src/platform.rs` - Modify: `crates/trusted-server-adapter-cloudflare/Cargo.toml` +- Modify: `crates/trusted-server-adapter-cloudflare/build.sh` - Modify: `crates/trusted-server-adapter-cloudflare/tests/routes.rs` - Create: `crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml` - Modify: `crates/trusted-server-adapter-spin/src/app.rs` +- Modify: `crates/trusted-server-adapter-spin/src/lib.rs` +- Modify: `crates/trusted-server-adapter-spin/src/middleware.rs` - Modify: `crates/trusted-server-adapter-spin/src/platform.rs` - Modify: `crates/trusted-server-adapter-spin/Cargo.toml` - Modify: `crates/trusted-server-adapter-spin/tests/routes.rs` - Create: `crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml` -- Create: `crates/trusted-server-integration-tests/fixtures/configs/viceroy-aps-runner-proxy-template.toml` +- Create: `crates/trusted-server-integration-tests/fixtures/configs/cloudflare-aps-runner-proxy-fixture.toml` +- Create: `crates/trusted-server-integration-tests/fixtures/cloudflare/aps-runner-proxy-service.js` - Modify: `crates/trusted-server-integration-tests/Cargo.toml` +- Modify: `crates/trusted-server-integration-tests/README.md` - Create: `crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs` - Create: `crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs` - Modify: `crates/trusted-server-integration-tests/tests/common/mod.rs` +- Modify: `crates/trusted-server-integration-tests/tests/common/runtime.rs` +- Modify: `crates/trusted-server-integration-tests/tests/environments/axum.rs` - Create: `crates/trusted-server-integration-tests/tests/environments/spin.rs` - Modify: `crates/trusted-server-integration-tests/tests/environments/mod.rs` - Modify: `crates/trusted-server-integration-tests/tests/environments/cloudflare.rs` @@ -565,7 +580,14 @@ Every task's regression suite therefore remains green in task order. - Create: `crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js` - Create: `scripts/integration-tests-aps-runner-proxy.sh` - Modify: `scripts/integration-tests-browser.sh` +- Modify: `scripts/integration-tests.sh` - Modify: `.github/workflows/integration-tests.yml` +- Modify: `.tool-versions` +- Modify: `Cargo.lock` +- Modify: `CLAUDE.md` +- Modify: `docs/guide/error-reference.md` +- Modify: `docs/guide/getting-started.md` +- Modify: `docs/guide/testing.md` - [ ] **Step 1: Write failing route and exact renderer-policy tests.** @@ -608,8 +630,11 @@ Every task's regression suite therefore remains green in task order. generation-inert late continuations; and - uses the common `APS_RUNNER_MAX_RESPONSE_BYTES = 8 MiB` cap. - Cloudflare must inspect the initial Workers headers before its generic adapter - strips encoding/length; concatenated duplicates remain visibly combined and fail. + Cloudflare must preserve `web_sys::Request.method()` before workers-rs conversion + and restore it at the reserved pre-router boundary because workers-rs maps extension + methods such as `PROPFIND` to `GET`. It must also inspect the initial Workers headers + before its generic adapter strips encoding/length; concatenated duplicates remain + visibly combined and fail. Spin must bypass `spin_sdk::http::send`, call the WASI HTTP outgoing handler with supported request options, and poll both response and body stream against a monotonic-clock total deadline. Axum and Fastly must enforce the same deadline, @@ -635,21 +660,40 @@ Every task's regression suite therefore remains green in task order. carries the compile-time APS logical URL. An integration-test-only platform resolver maps only that exact logical origin to the loopback fixture below target validation and immediately above the real adapter transport. Fastly uses a - generated Viceroy backend, Cloudflare uses a workerd service binding in the - dedicated Wrangler manifest, Spin uses the dedicated Spin manifest, and Axum uses - its real bounded client. Production constructors expose no resolver/override, and - release-build absence tests fail if the integration feature, fixture address, or - service binding is enabled or embedded. + generated static Viceroy backend with exact 4,000 ms first-byte and 250 ms + between-bytes timeouts. That static selection is simulator-only: production keeps + using Fastly's dynamic backend API with the same transport policy. Cloudflare uses + a workerd service binding in the dedicated Wrangler manifest, Spin uses the + dedicated Spin manifest, and Axum uses its real bounded client. Production + constructors expose no resolver/override, and release-build absence tests fail if + the integration feature, fixture address, or service binding is enabled or + embedded. `scripts/integration-tests-aps-runner-proxy.sh` builds the actual Fastly `wasm32-wasip1`, Cloudflare `wasm32-unknown-unknown`, and Spin `wasm32-wasip1` artifacts with that integration-only transport seam; launches Viceroy, `wrangler dev`/workerd, and `spin up`; waits for readiness; runs the identical `aps_runner_proxy` corpus against each runtime with `--test-threads=1`; and always - terminates process groups. The test asserts the logical URL and Host remain the - fixed APS target, the fixture is loopback-only, and actual runtime header - normalization, cancellation/resource drop, streaming cap, and - dispatch-through-final-byte clock produce the expected response. + terminates process groups. The test asserts that the request entering each raw + adapter transport still carries the compile-time logical URL and authority of the + fixed APS target. Where the runtime supports a backend/resolver authority override, + the fixture also asserts the wire `Host` is the APS host. A runtime-owned transport + such as Spin/Wasmtime that forbids guest `Host` writes must not synthesize one: its + integration-only seam instead validates the exact logical APS URL immediately + before lowering to the private loopback URI, carries a test-only logical-target + attestation to the fixture, and proves that neither an inbound request nor any + production configuration can select the loopback target. The corpus also proves the + fixture is loopback-only and that actual runtime header normalization, + cancellation/resource drop, streaming cap, and dispatch-through-final-byte clock + produce the expected response. + + The Fastly corpus requires Viceroy `0.19.0`. Diagnostics showed that its dynamic + backend path did not interrupt a mid-body stall even with the configured + between-bytes timeout, while a generated static backend with the exact 4,000/250 ms + policy did. The corpus therefore uses that feature-only static backend; production + continues to use Fastly's dynamic backend API and the same policy. This is a pin + and transport seam for the local Fastly simulator only; it is not an APS runner + pin, and no APS runner version, digest, or body enters the repository. - [ ] **Step 5: Implement the reserved dispatcher and live proxy response.** @@ -2113,6 +2157,13 @@ Every task's regression suite therefore remains green in task order. expose their exact bounded asynchronous frozen APIs. Creative guards auto-install from frozen boot configuration and both-false guards have zero DOM side effects. + Before enabling the Fastly production route, run the unchanged stall/slow-drip + deadline cases through a non-production Fastly Compute service and a controlled + staging-only backend. Viceroy proves the feature artifact and static simulator + seam, but it does not prove Fastly's production dynamic-backend body-timeout + behavior. The staging smoke must retain the strict five-second downstream ceiling + and block cutover on any timeout or late-continuation failure. + Run old-surface and new-surface fixture tests immediately before the switch, then require the entire suite green after it. Do not add a production selector, dual manifest, or shape autodetection. The temporarily unused server routes and old diff --git a/scripts/integration-tests-aps-runner-proxy.sh b/scripts/integration-tests-aps-runner-proxy.sh new file mode 100755 index 000000000..ac653aa95 --- /dev/null +++ b/scripts/integration-tests-aps-runner-proxy.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Run the hermetic APS runner-proxy corpus through one actual adapter runtime. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +if [ "$#" -ne 2 ] || [ "$1" != "--runtime" ]; then + echo "usage: $0 --runtime " >&2 + exit 2 +fi + +RUNTIME="$2" +case "$RUNTIME" in + axum|fastly|cloudflare|spin) ;; + *) + echo "unsupported APS runner-proxy runtime: $RUNTIME" >&2 + exit 2 + ;; +esac + +ORIGIN_PORT="${INTEGRATION_ORIGIN_PORT:-8888}" +HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +if [ -z "$HOST_TARGET" ]; then + echo "failed to detect the native Rust target" >&2 + exit 1 +fi + +export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" +export TRUSTED_SERVER__PUBLISHER__PROXY_SECRET="integration-test-proxy-secret" +export TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret-padded-32" +export TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false + +ABSENCE_PATTERNS=( + aps-runner-proxy-integration-test + TS_APS_RUNNER_PROXY_TEST_ENDPOINT + APS_RUNNER_PROXY_FIXTURE + aps_runner_proxy_test_endpoint + aps_runner_proxy_fixture + aps-runner-proxy-fixture-bounded + /integrations/aps/renderer/v1 + /integrations/aps/runner.js + x-ts-aps-logical-url +) + +# The fixed APS upstream URL is intentionally not an absence sentinel: the +# legacy production renderer already embeds that legitimate URL. The entries +# above are unique to the feature-only local routes and test transport. + +CARGO_TEST_PID="" +CARGO_TEST_PGID="" +PROCESS_GROUP_FILE="$(mktemp -t trusted-server-aps-pgids.XXXXXX)" +SHELL_PGID="$(ps -o pgid= -p "$$" 2>/dev/null | tr -d '[:space:]' || true)" + +terminate_registered_process_groups() { + local pgid + local actual_pgid + while IFS= read -r pgid; do + if [[ ! "$pgid" =~ ^[1-9][0-9]*$ ]] || [ "$pgid" = "$SHELL_PGID" ]; then + continue + fi + actual_pgid="$(ps -o pgid= -p "$pgid" 2>/dev/null | tr -d '[:space:]' || true)" + if [ "$actual_pgid" = "$pgid" ]; then + kill -TERM -- "-$pgid" 2>/dev/null || true + fi + done < "$PROCESS_GROUP_FILE" +} + +terminate_cargo_test() { + if [ -z "$CARGO_TEST_PID" ]; then + return + fi + if [ -n "$CARGO_TEST_PGID" ]; then + kill -TERM -- "-$CARGO_TEST_PGID" 2>/dev/null || true + else + kill -TERM "$CARGO_TEST_PID" 2>/dev/null || true + fi + wait "$CARGO_TEST_PID" 2>/dev/null || true +} + +cleanup() { + local status="$?" + trap - EXIT INT TERM + terminate_cargo_test + terminate_registered_process_groups + if [[ "$PROCESS_GROUP_FILE" = /* ]] && [ -f "$PROCESS_GROUP_FILE" ]; then + rm -f -- "$PROCESS_GROUP_FILE" + fi + exit "$status" +} + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +assert_release_absence() { + local artifact="$1" + local rg_args=() + local pattern + for pattern in "${ABSENCE_PATTERNS[@]}"; do + rg_args+=(--regexp "$pattern") + done + if strings "$artifact" | rg --fixed-strings "${rg_args[@]}"; then + echo "production artifact contains an APS proxy integration sentinel: $artifact" >&2 + exit 1 + fi +} + +echo "==> Building and checking the production $RUNTIME artifact..." +case "$RUNTIME" in + axum) + cargo build --package trusted-server-adapter-axum --release + assert_release_absence target/release/trusted-server-axum + cargo build --package trusted-server-adapter-axum --release \ + --features aps-runner-proxy-integration-test + export AXUM_BINARY_PATH="$REPO_ROOT/target/release/trusted-server-axum" + ;; + fastly) + cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 + assert_release_absence \ + target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm + cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 \ + --features aps-runner-proxy-integration-test + INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" \ + ./scripts/generate-integration-viceroy-configs.sh + export WASM_BINARY_PATH="$REPO_ROOT/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm" + export VICEROY_CONFIG_PATH="$REPO_ROOT/target/integration-test-artifacts/configs/viceroy.toml" + ;; + cloudflare) + bash crates/trusted-server-adapter-cloudflare/build.sh + assert_release_absence crates/trusted-server-adapter-cloudflare/build/index.js + assert_release_absence crates/trusted-server-adapter-cloudflare/build/index_bg.wasm + TS_WORKER_BUILD_FEATURES="cloudflare,aps-runner-proxy-integration-test" \ + bash crates/trusted-server-adapter-cloudflare/build.sh + export CLOUDFLARE_WRANGLER_DIR="$REPO_ROOT/crates/trusted-server-adapter-cloudflare" + ;; + spin) + cargo build --package trusted-server-adapter-spin --release --target wasm32-wasip1 \ + --features spin + assert_release_absence \ + target/wasm32-wasip1/release/trusted_server_adapter_spin.wasm + cargo build --package trusted-server-adapter-spin --release --target wasm32-wasip1 \ + --features spin,aps-runner-proxy-integration-test + export WASM_BINARY_PATH="$REPO_ROOT/target/wasm32-wasip1/release/trusted_server_adapter_spin.wasm" + ;; +esac + +echo "==> Running the APS runner-proxy corpus through $RUNTIME..." +TEST_COMMAND=( + cargo test + --manifest-path crates/trusted-server-integration-tests/Cargo.toml + --features aps-runner-proxy + --target "$HOST_TARGET" + --test aps_runner_proxy + actual_adapter_proxy_corpus + -- --ignored --test-threads=1 +) + +if command -v setsid >/dev/null 2>&1; then + setsid env \ + APS_RUNNER_PROXY_RUNTIME="$RUNTIME" \ + APS_RUNNER_PROXY_PROCESS_GROUP_FILE="$PROCESS_GROUP_FILE" \ + INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" \ + RUST_LOG="${RUST_LOG:-info}" \ + "${TEST_COMMAND[@]}" & +else + # BSD/macOS does not provide `setsid`. Bash job control still launches a + # background job in its own process group, so the cleanup trap can terminate + # Cargo, the test binary, and every runtime it starts as one unit. + set -m + env \ + APS_RUNNER_PROXY_RUNTIME="$RUNTIME" \ + APS_RUNNER_PROXY_PROCESS_GROUP_FILE="$PROCESS_GROUP_FILE" \ + INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" \ + RUST_LOG="${RUST_LOG:-info}" \ + "${TEST_COMMAND[@]}" & + set +m +fi +CARGO_TEST_PID="$!" + +CHILD_PGID="$(ps -o pgid= -p "$CARGO_TEST_PID" 2>/dev/null | tr -d '[:space:]' || true)" +if [[ "$CHILD_PGID" =~ ^[1-9][0-9]*$ ]] && [ "$CHILD_PGID" != "$SHELL_PGID" ]; then + CARGO_TEST_PGID="$CHILD_PGID" +else + echo "failed to isolate the APS corpus in a dedicated process group" >&2 + terminate_cargo_test + CARGO_TEST_PID="" + exit 1 +fi + +if wait "$CARGO_TEST_PID"; then + TEST_STATUS=0 +else + TEST_STATUS="$?" +fi +CARGO_TEST_PID="" +CARGO_TEST_PGID="" +exit "$TEST_STATUS" diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index debedff8d..4940300a8 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -7,7 +7,7 @@ # # Prerequisites: # - Docker running -# - Viceroy installed: cargo install viceroy --version 0.17.0 --locked --force +# - Viceroy installed: cargo install viceroy --version 0.19.0 --locked --force # - wasm32-wasip1 target: rustup target add wasm32-wasip1 # - Node.js with npm available # @@ -23,6 +23,17 @@ NODE_VERSION="$(grep '^nodejs ' .tool-versions | awk '{print $2}')" FRAMEWORKS_VALUE="${TS_BROWSER_FRAMEWORKS:-nextjs wordpress}" FRAMEWORKS_VALUE="${FRAMEWORKS_VALUE//,/ }" read -r -a FRAMEWORKS <<< "$FRAMEWORKS_VALUE" +APS_V1_VALUE="${TS_TEST_APS_V1:-0}" +APS_V1_FEATURE_ARGS=() + +case "$APS_V1_VALUE" in + 0) ;; + 1) APS_V1_FEATURE_ARGS=(--features aps-runner-proxy-integration-test) ;; + *) + echo "TS_TEST_APS_V1 must be exactly 0 or 1" >&2 + exit 1 + ;; +esac if [ -z "$NODE_VERSION" ]; then echo "Failed to detect Node.js version from .tool-versions" >&2 @@ -51,7 +62,8 @@ TRUSTED_SERVER__PUBLISHER__PROXY_SECRET="integration-test-proxy-secret" \ TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret-padded-32" \ TRUSTED_SERVER__EC__PARTNERS='[{"name":"Integration Test Partner","source_domain":"inttest.example.com","bidstream_enabled":true,"api_token":"integration-test-token-alpha-32-bytes-ok"},{"name":"Integration Test Partner 2","source_domain":"inttest2.example.com","bidstream_enabled":true,"api_token":"integration-test-token-bravo-32-bytes-ok"}]' \ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ - cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 + cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 \ + "${APS_V1_FEATURE_ARGS[@]}" echo "==> Generating Viceroy configs..." INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" ./scripts/generate-integration-viceroy-configs.sh diff --git a/scripts/integration-tests.sh b/scripts/integration-tests.sh index 96e492f40..f4d8196b3 100755 --- a/scripts/integration-tests.sh +++ b/scripts/integration-tests.sh @@ -7,7 +7,7 @@ # # Prerequisites: # - Docker running -# - Viceroy installed: cargo install viceroy --version 0.17.0 --locked --force +# - Viceroy installed: cargo install viceroy --version 0.19.0 --locked --force # - wasm32-wasip1 target: rustup target add wasm32-wasip1 # set -euo pipefail From 0fbd760fc4ce881280900091e6216dd119ef93b6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:40:07 -0700 Subject: [PATCH 020/194] refactor(tsjs): enforce layering and external-global ownership --- .../lib/eslint-rules/no-adtech-globals.js | 545 +++++++++++++++++ crates/trusted-server-js/lib/eslint.config.js | 110 ++++ .../trusted-server-js/lib/package-lock.json | 559 ++++++++++++++++++ crates/trusted-server-js/lib/package.json | 4 +- .../lib/src/adapters/googletag.ts | 33 ++ .../lib/src/adapters/messaging.ts | 42 ++ .../lib/src/adapters/prebid.ts | 33 ++ .../lib/src/composition/browser.ts | 74 +++ .../lib/test/composition/browser.test.ts | 87 +++ .../test/eslint/no-adtech-globals.test.mjs | 339 +++++++++++ crates/trusted-server-js/lib/vitest.config.ts | 12 +- ...8-04-aps-tsjs-resilience-implementation.md | 11 +- 12 files changed, 1841 insertions(+), 8 deletions(-) create mode 100644 crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js create mode 100644 crates/trusted-server-js/lib/src/adapters/googletag.ts create mode 100644 crates/trusted-server-js/lib/src/adapters/messaging.ts create mode 100644 crates/trusted-server-js/lib/src/adapters/prebid.ts create mode 100644 crates/trusted-server-js/lib/src/composition/browser.ts create mode 100644 crates/trusted-server-js/lib/test/composition/browser.test.ts create mode 100644 crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs 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..2017d2abb --- /dev/null +++ b/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js @@ -0,0 +1,545 @@ +const ADTECH_GLOBALS = new Set(['googletag', 'pbjs']); +const GLOBAL_ROOTS = new Set(['globalThis', 'self', 'window']); + +export const LEGACY_ADTECH_GLOBAL_ALLOWLIST = Object.freeze([ + 'src/integrations/gpt/index.ts', + 'src/integrations/gpt_diagnostics/observer.ts', + 'src/integrations/prebid/index.ts', +]); + +export const LEGACY_RESTRICTED_IMPORT_ALLOWLIST = Object.freeze([ + 'src/core/auction.ts', + 'src/core/request.ts', + 'src/integrations/gpt/index.ts', + 'src/integrations/prebid/index.ts', +]); + +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: { + allowFiles: { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + }, + 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 allowFiles = new Set(context.options[0]?.allowFiles ?? []); + const isAdapter = relativeFilename.startsWith('src/adapters/'); + + if (isAdapter || allowFiles.has(relativeFilename)) 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..efbaed5fe 100644 --- a/crates/trusted-server-js/lib/eslint.config.js +++ b/crates/trusted-server-js/lib/eslint.config.js @@ -6,6 +6,74 @@ import importPlugin from 'eslint-plugin-import'; import jsdoc from 'eslint-plugin-jsdoc'; import unicorn from 'eslint-plugin-unicorn'; +import noAdtechGlobals, { + LEGACY_ADTECH_GLOBAL_ALLOWLIST, + LEGACY_RESTRICTED_IMPORT_ALLOWLIST, +} 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', + '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 +86,16 @@ export default [ // Project rules { files: ['**/*.ts', '**/*.tsx'], + settings: { + 'import/resolver': { + typescript: { + project: './tsconfig.json', + }, + node: { + extensions: ['.js', '.mjs', '.ts', '.tsx'], + }, + }, + }, languageOptions: { parser: tseslint.parser, parserOptions: { @@ -28,6 +106,11 @@ export default [ plugins: { import: importPlugin, jsdoc, + tsjs: { + rules: { + 'no-adtech-globals': noAdtechGlobals, + }, + }, unicorn, '@typescript-eslint': tseslint.plugin, }, @@ -37,6 +120,33 @@ export default [ 'import/order': ['error', { 'newlines-between': 'always' }], }, }, + // New architecture paths are clean by default. These exact legacy files are + // removed from the exemption list during the Task 22 hard cutover. + { + files: ['src/**/*.ts', 'src/**/*.tsx'], + rules: { + 'tsjs/no-adtech-globals': [ + 'error', + { + allowFiles: LEGACY_ADTECH_GLOBAL_ALLOWLIST, + rootDirectory: import.meta.dirname, + }, + ], + }, + }, + { + files: ['src/**/*.ts', 'src/**/*.tsx'], + ignores: LEGACY_RESTRICTED_IMPORT_ALLOWLIST, + rules: { + 'import/no-restricted-paths': [ + 'error', + { + basePath: import.meta.dirname, + zones: ARCHITECTURE_RESTRICTED_LAYER_ZONES, + }, + ], + }, + }, // Honor the `_`-prefix convention for intentionally unused bindings in every // linted file: tseslint recommended enables the rule globally, so scoping // this to *.ts(x) would leave .mjs at the pattern-less defaults diff --git a/crates/trusted-server-js/lib/package-lock.json b/crates/trusted-server-js/lib/package-lock.json index 588ccd6be..13fa70458 100644 --- a/crates/trusted-server-js/lib/package-lock.json +++ b/crates/trusted-server-js/lib/package-lock.json @@ -18,6 +18,7 @@ "@typescript-eslint/parser": "^8.6.0", "eslint": "^9.10.0", "eslint-config-prettier": "^10.1.8", + "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jsdoc": "^62.5.4", "eslint-plugin-unicorn": "^62.0.0", @@ -1776,6 +1777,17 @@ "node": ">=20.19.0" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "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", @@ -2562,6 +2574,28 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "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, + "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-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -2939,6 +2973,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", @@ -3240,6 +3285,353 @@ "url": "https://opencollective.com/eslint" } }, + "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", + "optional": true, + "os": [ + "android" + ] + }, + "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", + "optional": true, + "os": [ + "android" + ] + }, + "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", + "optional": true, + "os": [ + "darwin" + ] + }, + "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", + "optional": true, + "os": [ + "darwin" + ] + }, + "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", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/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/@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.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -4795,6 +5187,31 @@ "eslint": ">=7.0.0" } }, + "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": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "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": { + "unrs-resolver": { + "optional": true + } + } + }, "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", @@ -4817,6 +5234,41 @@ "ms": "^2.1.1" } }, + "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": "ISC", + "dependencies": { + "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": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, "node_modules/eslint-module-utils": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", @@ -4851,6 +5303,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5619,6 +6072,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6148,6 +6614,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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": { + "semver": "^7.7.1" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -6877,6 +7353,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", @@ -7604,6 +8096,16 @@ "node": ">=4" } }, + "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", + "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", @@ -8071,6 +8573,16 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "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", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -8427,6 +8939,14 @@ "json5": "lib/cli.js" } }, + "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": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -8679,6 +9199,45 @@ "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", + "peer": true, + "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", diff --git a/crates/trusted-server-js/lib/package.json b/crates/trusted-server-js/lib/package.json index 770530be2..312cc7820 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -13,7 +13,8 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc -p tsconfig.json --noEmit", - "lint": "eslint . --max-warnings=0", + "test:architecture": "node --test test/eslint/no-adtech-globals.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}\"" @@ -29,6 +30,7 @@ "@typescript-eslint/parser": "^8.6.0", "eslint": "^9.10.0", "eslint-config-prettier": "^10.1.8", + "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jsdoc": "^62.5.4", "eslint-plugin-unicorn": "^62.0.0", 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..945ae1011 --- /dev/null +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -0,0 +1,33 @@ +/** The live state of the publisher-owned `window.googletag` binding. */ +export type GoogletagBindingStatus = 'present' | 'pending' | 'incompatible'; + +/** Narrow GPT boundary consumed by kernel sessions and services. */ +export interface GoogletagAdapter { + bindingStatus(): GoogletagBindingStatus; +} + +/** Browser surface owned by the concrete GPT adapter. */ +export interface GoogletagGlobalTarget { + googletag?: unknown; +} + +function bindingStatus(value: unknown): GoogletagBindingStatus { + if (value === undefined || value === null) return 'pending'; + return typeof value === 'object' || typeof value === 'function' ? 'present' : 'incompatible'; +} + +/** Create the sole production reader/writer boundary for `window.googletag`. */ +export function createBrowserGoogletagAdapter( + target: GoogletagGlobalTarget = window as unknown as GoogletagGlobalTarget +): GoogletagAdapter { + return Object.freeze({ + bindingStatus: () => bindingStatus(target.googletag), + }); +} + +/** Create a side-effect-free GPT boundary for tests and unavailable environments. */ +export function createNoopGoogletagAdapter(): GoogletagAdapter { + return Object.freeze({ + bindingStatus: () => 'pending', + }); +} 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..d0f2912a3 --- /dev/null +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -0,0 +1,42 @@ +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; +} + +/** Cross-window boundary consumed by the kernel's capability recognizer. */ +export interface MessagingAdapter { + installCaptureListener(listener: CaptureMessageListener): () => void; +} + +/** + * 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 +): MessagingAdapter { + return Object.freeze({ + installCaptureListener(listener: CaptureMessageListener): () => void { + target.addEventListener('message', listener, true); + let installed = true; + + return () => { + if (!installed) return; + installed = false; + target.removeEventListener('message', listener, true); + }; + }, + }); +} + +/** Create a side-effect-free messaging boundary for tests and non-DOM runtimes. */ +export function createNoopMessagingAdapter(): MessagingAdapter { + return Object.freeze({ + installCaptureListener: () => () => undefined, + }); +} 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..f44cc8215 --- /dev/null +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -0,0 +1,33 @@ +/** The live state of the publisher-owned `window.pbjs` binding. */ +export type PrebidBindingStatus = 'present' | 'pending' | 'incompatible'; + +/** Narrow Prebid boundary consumed by kernel sessions and services. */ +export interface PrebidAdapter { + bindingStatus(): PrebidBindingStatus; +} + +/** Browser surface owned by the concrete Prebid adapter. */ +export interface PrebidGlobalTarget { + pbjs?: unknown; +} + +function bindingStatus(value: unknown): PrebidBindingStatus { + if (value === undefined || value === null) return 'pending'; + return typeof value === 'object' || typeof value === 'function' ? 'present' : 'incompatible'; +} + +/** Create the sole production reader/writer boundary for `window.pbjs`. */ +export function createBrowserPrebidAdapter( + target: PrebidGlobalTarget = window as unknown as PrebidGlobalTarget +): PrebidAdapter { + return Object.freeze({ + bindingStatus: () => bindingStatus(target.pbjs), + }); +} + +/** Create a side-effect-free Prebid boundary for tests and unavailable environments. */ +export function createNoopPrebidAdapter(): PrebidAdapter { + return Object.freeze({ + bindingStatus: () => 'pending', + }); +} 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..d83cf6ed4 --- /dev/null +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -0,0 +1,74 @@ +import { + createBrowserGoogletagAdapter, + createNoopGoogletagAdapter, + type GoogletagAdapter, + type GoogletagGlobalTarget, +} from '../adapters/googletag'; +import { + createBrowserMessagingAdapter, + createNoopMessagingAdapter, + type MessageEventTarget, + type MessagingAdapter, +} from '../adapters/messaging'; +import { + createBrowserPrebidAdapter, + createNoopPrebidAdapter, + type PrebidAdapter, + type PrebidGlobalTarget, +} from '../adapters/prebid'; + +export interface BrowserAdapters { + readonly googletag: GoogletagAdapter; + readonly messaging: MessagingAdapter; + readonly prebid: PrebidAdapter; +} + +export interface BrowserComposition { + readonly adapters: Readonly; +} + +export type BrowserAdapterTarget = GoogletagGlobalTarget & PrebidGlobalTarget & MessageEventTarget; + +export interface BrowserCompositionOptions { + readonly adapters?: Partial; + readonly target?: BrowserAdapterTarget; +} + +/** + * 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 googletag = + options.adapters?.googletag ?? + (options.target + ? createBrowserGoogletagAdapter(options.target) + : createBrowserGoogletagAdapter()); + const messaging = + options.adapters?.messaging ?? + (options.target + ? createBrowserMessagingAdapter(options.target) + : createBrowserMessagingAdapter()); + 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(), + }), + }); +} 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..4dfea74a4 --- /dev/null +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GoogletagAdapter } from '../../src/adapters/googletag'; +import type { CaptureMessageListener, MessagingAdapter } from '../../src/adapters/messaging'; +import type { PrebidAdapter } from '../../src/adapters/prebid'; +import { + createBrowserComposition, + createNoopBrowserComposition, +} from '../../src/composition/browser'; + +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>(), + }; +} + +describe('browser composition', () => { + 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('present'); + expect(composition.adapters.prebid.bindingStatus()).toBe('present'); + + target.googletag = 1; + target.pbjs = 'not-prebid'; + expect(composition.adapters.googletag.bindingStatus()).toBe('incompatible'); + expect(composition.adapters.prebid.bindingStatus()).toBe('incompatible'); + }); + + 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(target.addEventListener).toHaveBeenCalledTimes(1); + expect(target.addEventListener).toHaveBeenCalledWith('message', listener, true); + + dispose(); + dispose(); + expect(target.removeEventListener).toHaveBeenCalledTimes(1); + expect(target.removeEventListener).toHaveBeenCalledWith('message', listener, true); + }); + + it('uses exact injected fakes without constructing concrete adapters', () => { + const googletag: GoogletagAdapter = { + bindingStatus: () => 'present', + }; + const prebid: PrebidAdapter = { + bindingStatus: () => 'incompatible', + }; + const messaging: MessagingAdapter = { + installCaptureListener: () => vi.fn(), + }; + + 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'); + expect(() => composition.adapters.messaging.installCaptureListener(listener)()).not.toThrow(); + expect(listener).not.toHaveBeenCalled(); + }); +}); 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..d03467e68 --- /dev/null +++ b/crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs @@ -0,0 +1,339 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +import { ESLint, Linter } from 'eslint'; + +import noAdtechGlobals, { + LEGACY_ADTECH_GLOBAL_ALLOWLIST, + LEGACY_RESTRICTED_IMPORT_ALLOWLIST, +} from '../../eslint-rules/no-adtech-globals.js'; +import { + ARCHITECTURE_INTEGRATION_DIRECTORIES, + ARCHITECTURE_RESTRICTED_LAYER_ZONES, +} 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', + { + allowFiles: LEGACY_ADTECH_GLOBAL_ALLOWLIST, + }, + ], + }, + }, + ], + { 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')); +} + +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('temporary allowlists are exact, narrow, and inventoried for Task 22 removal', () => { + assert.deepEqual(LEGACY_ADTECH_GLOBAL_ALLOWLIST, [ + 'src/integrations/gpt/index.ts', + 'src/integrations/gpt_diagnostics/observer.ts', + 'src/integrations/prebid/index.ts', + ]); + assert.deepEqual(LEGACY_RESTRICTED_IMPORT_ALLOWLIST, [ + 'src/core/auction.ts', + 'src/core/request.ts', + 'src/integrations/gpt/index.ts', + 'src/integrations/prebid/index.ts', + ]); + + assert.deepEqual(lint('window.googletag;', 'src/integrations/gpt/index.ts'), []); + assertRejected('window.googletag;', 'src/integrations/gpt/index-copy.ts'); + assertRejected('window.googletag;', 'src/new/src/integrations/gpt/index.ts'); +}); + +test('every temporary exemption still maps to an active legacy violation', async () => { + const strictEslint = new ESLint({ + cwd: packageRoot, + overrideConfig: { + files: ['src/**/*.ts', 'src/**/*.tsx'], + rules: { + 'tsjs/no-adtech-globals': ['error', { allowFiles: [] }], + 'import/no-restricted-paths': [ + 'error', + { + basePath: packageRoot, + zones: ARCHITECTURE_RESTRICTED_LAYER_ZONES, + }, + ], + }, + }, + }); + + for (const relativeFilename of LEGACY_ADTECH_GLOBAL_ALLOWLIST) { + const [result] = await strictEslint.lintFiles([relativeFilename]); + assert.ok(result); + assert.ok( + result.messages.some((message) => message.ruleId === 'tsjs/no-adtech-globals'), + `${relativeFilename} no longer needs its ad-tech-global exemption` + ); + } + + for (const relativeFilename of LEGACY_RESTRICTED_IMPORT_ALLOWLIST) { + const [result] = await strictEslint.lintFiles([relativeFilename]); + assert.ok(result); + assert.ok( + result.messages.some((message) => message.ruleId === 'import/no-restricted-paths'), + `${relativeFilename} no longer needs its restricted-import exemption` + ); + } +}); + +test('restricted paths enforce dependency direction and exact target-file exemptions', async () => { + const eslint = new ESLint({ cwd: packageRoot }); + const restrictedRuleId = 'import/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.deepEqual( + await restrictedMessages("import '../integrations/aps/render';", 'src/core/request.ts'), + [] + ); + 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('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/vitest.config.ts b/crates/trusted-server-js/lib/vitest.config.ts index 02f33a590..c844ba815 100644 --- a/crates/trusted-server-js/lib/vitest.config.ts +++ b/crates/trusted-server-js/lib/vitest.config.ts @@ -21,10 +21,14 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, - // This suite deliberately uses node:test + vm so it executes the generated - // ES5 artifact without Vite transforms. CI invokes it separately with - // `node --test`; importing it through Vitest rewrites import.meta.url. - exclude: [...configDefaults.exclude, 'test/contract/aps-renderer-es5.test.mjs'], + // 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', + ], // Run tests in the main thread to avoid spawning // child processes/workers, which are blocked in this sandbox. threads: false, diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index 3240b64b6..55d64d3e9 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -808,7 +808,9 @@ Every task's regression suite therefore remains green in task order. **Files:** - Modify: `crates/trusted-server-js/lib/eslint.config.js` +- Modify: `crates/trusted-server-js/lib/package-lock.json` - Modify: `crates/trusted-server-js/lib/package.json` +- Modify: `crates/trusted-server-js/lib/vitest.config.ts` - Create: `crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js` - Create: `crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs` - Create: `crates/trusted-server-js/lib/src/adapters/googletag.ts` @@ -822,9 +824,12 @@ Every task's regression suite therefore remains green in task order. inside adapters and kernel `window.tsjs`/messaging code. - [ ] **Step 2: Configure `import/no-restricted-paths` for the dependency direction in the** - source-shape diagram. Add a narrow, enumerated temporary allowlist for current - production files that still violate the target (`core/request.ts`, GPT/Prebid - integration files, and diagnostics files found by the initial lint inventory). + source-shape diagram. Resolve imports with the package's TypeScript bundler + semantics so explicit `.js` specifiers cannot bypass `.ts` boundaries. Add a + narrow, enumerated temporary allowlist for current + production files that still violate the target (`core/auction.ts`, + `core/request.ts`, GPT/Prebid integration files, and the diagnostics observer + found by the executable lint inventory). New files receive no exemption. Check the allowlist into the lint test and make Task 22 fail if any entry remains. From 0db0d0741cd8807fafdcaf40bb86bca8bb11c4c8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:17:28 -0700 Subject: [PATCH 021/194] docs(tsjs): restore the toolchain upgrade task --- ...8-04-aps-tsjs-resilience-implementation.md | 84 ++++++++++++++++++- ...s-render-fix-and-tsjs-resilience-design.md | 17 +++- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index 55d64d3e9..bfa9d74e8 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -198,8 +198,10 @@ Every task's regression suite therefore remains green in task order. Any pre-existing failure is recorded in the execution notes; it is not silently attributed to this work. -- [ ] **Step 3: Keep the lockfile-resolved compiler; dependency upgrading is not part of this** - work. Add the checked-in `typecheck` script: +- [ ] **Step 3: Keep the lockfile-resolved compiler unchanged only while capturing the** + pre-change baseline. Task 7A performs the intentional package and TypeScript + upgrade before the remaining TSJS runtime is built. Add the checked-in + `typecheck` script: ```json "typecheck": "tsc -p tsconfig.json --noEmit" @@ -918,6 +920,84 @@ Every task's regression suite therefore remains green in task order. npm --prefix crates/trusted-server-js/lib run typecheck ``` +### Task 7A: Upgrade the TSJS package and TypeScript toolchain + +**Files:** + +- Modify: `crates/trusted-server-js/lib/package.json` +- Modify: `crates/trusted-server-js/lib/package-lock.json` +- Modify: `crates/trusted-server-js/lib/eslint.config.js` +- Modify if required by an actual compatibility failure: + `crates/trusted-server-js/lib/tsconfig.json` +- Modify if required by an actual compatibility failure: + `crates/trusted-server-js/lib/vitest.config.ts` +- Modify only the exact TSJS source/test/build files that a new compiler or tool + correctly rejects; do not mix in behavior changes or unrelated refactors + +- [ ] **Step 1: Capture an executable package-compatibility inventory before changing the** + lockfile. Run `npm outdated --json`, query direct-package peer/engine ranges, + and record the current Node/npm/compiler versions. Select the newest stable, + mutually compatible direct toolchain supported by the repository-pinned Node + major. Upgrade TypeScript to the newest stable release supported by the newest + `typescript-eslint`; do not install a newer TypeScript that its parser explicitly + excludes. Keep `@types/node` on the repository's pinned Node major. + + `prebid.js` is the one explicit package exception: pin it to exactly `10.26.0`, as + required by this design's external pure-Prebid artifact contract. Do not use this + task to adopt Prebid 11 or change the selected Prebid modules. If the latest ESLint + major is incompatible with the unmaintained `eslint-plugin-import`, migrate the + existing import rules to the maintained compatible `eslint-plugin-import-x` rather + than holding the rest of the lint toolchain back. Remove redundant direct + `@typescript-eslint/parser`/plugin declarations when the used `typescript-eslint` + package already owns those exact dependencies. + +- [ ] **Step 2: Update direct package ranges and regenerate `package-lock.json` through npm.** + Do not hand-edit lock entries. Require a peer-clean `npm ls --all` and a second + clean `npm ci` from the generated lock. Treat invalid, missing, or extraneous + nodes as failures. Do not run `npm audit fix --force`; audit findings that remain + solely behind the mandated Prebid pin are reported, not silently solved by + violating the artifact contract. + +- [ ] **Step 3: Make only compatibility edits proven necessary by the upgraded tools.** Keep + all strict compiler flags and architecture rules enabled. A new compiler/linter + diagnostic gets a source fix or a documented, narrow configuration correction; + it is not suppressed globally. Production bundle entry points, bundle ids, + integration behavior, the APS runner policy, and the Prebid module set must not + change in this task. + +- [ ] **Step 4: Verify the upgraded toolchain and every shipped artifact:** + + ```bash + npm --prefix crates/trusted-server-js/lib ci + npm --prefix crates/trusted-server-js/lib ls --all + npm --prefix crates/trusted-server-js/lib run typecheck + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run format + npm --prefix crates/trusted-server-js/lib test -- --run + npm --prefix crates/trusted-server-js/lib run build + node --test crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs + ``` + + Also print the resolved Node, npm, TypeScript, ESLint, Vite, Vitest, and jsdom + versions into the task verification evidence. Re-run the external Prebid artifact + integration test and prove both `package.json` and the lockfile resolve Prebid + `10.26.0` exactly. + +- [ ] **Step 5: Commit the toolchain upgrade as its own rollback boundary.** + + ```bash + git add \ + crates/trusted-server-js/lib/package.json \ + crates/trusted-server-js/lib/package-lock.json \ + crates/trusted-server-js/lib/eslint.config.js \ + crates/trusted-server-js/lib/tsconfig.json \ + crates/trusted-server-js/lib/vitest.config.ts + git commit -m "chore(tsjs): upgrade the package and TypeScript toolchain" + ``` + + Add only compatibility files that actually changed to the explicit staging list; + do not use broad staging. + ### Task 8: Implement bootstrap ownership and the single runtime registry, dormant **Files:** diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index d4cb142bc..4249814a3 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -2717,9 +2717,20 @@ their internal behavior is not otherwise rewritten. ### 5.12 TypeScript and performance gates -The lockfile compiler is the authority. CI runs a checked-in `typecheck` script with -`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, -`verbatimModuleSyntax`, `noImplicitOverride`, and +Before the coordinated runtime implementation proceeds, the TSJS direct development +toolchain is upgraded to the newest stable, mutually compatible versions supported +by the repository-pinned Node major. TypeScript advances to the newest stable release +inside the latest `typescript-eslint` parser's declared support range; an unsupported +compiler/parser pairing is not accepted merely to claim a higher version. The +external artifact dependency remains exactly `prebid.js@10.26.0`, and Node type +declarations remain on the pinned Node major. Those are explicit compatibility and +artifact-contract constraints, not permission to leave the rest of the toolchain +stale. The upgrade must pass a clean `npm ci`, a peer-clean `npm ls --all`, complete +build/lint/typecheck/tests, and exact Prebid artifact verification. + +After that upgrade, the lockfile compiler is the authority. CI runs a checked-in +`typecheck` script with `strict`, `noUncheckedIndexedAccess`, +`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `noImplicitOverride`, and `useUnknownInCatchVariables`. Production bundles contain no dynamic imports. Before implementation, CI records deterministic gzip/Brotli baselines for minimal, From 71eda1e8f1c885a2898322a42f1f3dfdf4895b17 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:28:34 -0700 Subject: [PATCH 022/194] Add transactional TSJS integration modules --- .../trusted-server-js/lib/src/core/types.ts | 13 + .../lib/src/kernel/disposable.ts | 119 ++ .../lib/src/kernel/integration_registry.ts | 794 +++++++++++ .../lib/test/kernel/disposable.test.ts | 81 ++ .../test/kernel/integration_registry.test.ts | 1237 +++++++++++++++++ 5 files changed, 2244 insertions(+) create mode 100644 crates/trusted-server-js/lib/src/kernel/disposable.ts create mode 100644 crates/trusted-server-js/lib/src/kernel/integration_registry.ts create mode 100644 crates/trusted-server-js/lib/test/kernel/disposable.test.ts create mode 100644 crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index a337ad048..6a29e0bd0 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -317,6 +317,19 @@ export interface GptDiagnosticsApi { hide(): void; } +/** Release-internal integration inventory emitted by the server before core. */ +export interface BootManifestIntegrationV1 { + readonly id: string; + readonly required: true; +} + +/** Exact bundle set and injection order required by one TSJS release. */ +export interface BootManifestV1 { + readonly version: 1; + readonly releaseId: string; + readonly integrations: readonly BootManifestIntegrationV1[]; +} + export interface TsjsApi { version: string; que: Array<() => void>; diff --git a/crates/trusted-server-js/lib/src/kernel/disposable.ts b/crates/trusted-server-js/lib/src/kernel/disposable.ts new file mode 100644 index 000000000..52c6cf411 --- /dev/null +++ b/crates/trusted-server-js/lib/src/kernel/disposable.ts @@ -0,0 +1,119 @@ +export type DisposeCallback = () => void; +export type DisposalErrorHandler = (error: unknown) => void; + +const ignoreDisposalError: DisposalErrorHandler = () => undefined; + +function isThenable(value: unknown): value is PromiseLike { + return ( + (typeof value === 'object' || typeof value === 'function') && + value !== null && + typeof (value as { then?: unknown }).then === 'function' + ); +} + +/** + * A synchronous, owned disposer stack for browser targets that do not provide the + * TC39 DisposableStack proposal. + */ +export class DisposableStack { + private readonly abortController = new AbortController(); + private readonly callbacks: DisposeCallback[] = []; + private isDisposed = false; + + public constructor(private readonly onError: DisposalErrorHandler = ignoreDisposalError) {} + + public get disposed(): boolean { + return this.isDisposed; + } + + public get signal(): AbortSignal { + return this.abortController.signal; + } + + public onDispose(callback: DisposeCallback): void { + if (typeof callback !== 'function') { + throw new TypeError('A disposer must be a function'); + } + + if (this.isDisposed) { + this.run(callback); + return; + } + + this.callbacks.push(callback); + } + + public dispose(): void { + if (this.isDisposed) return; + this.isDisposed = true; + this.abortController.abort(); + + for (let index = this.callbacks.length - 1; index >= 0; index -= 1) { + const callback = this.callbacks[index]; + if (callback) this.run(callback); + } + this.callbacks.length = 0; + } + + private run(callback: DisposeCallback): void { + try { + const returned = callback() as unknown; + if (isThenable(returned)) { + void Promise.resolve(returned).catch((error: unknown) => this.report(error)); + } + } catch (error) { + this.report(error); + } + } + + private report(error: unknown): void { + try { + this.onError(error); + } catch { + // Error reporting is observational and must not break remaining cleanup. + } + } +} + +/** First-terminal-wins settlement coupled to synchronous resource disposal. */ +export class TerminalLatch { + private readonly disposables: DisposableStack; + private readonly resolveCompletion: (value: T | PromiseLike) => void; + private isTerminal = false; + private terminalValue: T | undefined; + public readonly completion: Promise; + + public constructor(onDisposalError: DisposalErrorHandler = ignoreDisposalError) { + this.disposables = new DisposableStack(onDisposalError); + let resolveCompletion: ((value: T | PromiseLike) => void) | undefined; + this.completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + this.resolveCompletion = resolveCompletion as (value: T | PromiseLike) => void; + } + + public get terminal(): boolean { + return this.isTerminal; + } + + public get value(): T | undefined { + return this.terminalValue; + } + + public get signal(): AbortSignal { + return this.disposables.signal; + } + + public onDispose(callback: DisposeCallback): void { + this.disposables.onDispose(callback); + } + + public trySettle(value: T): boolean { + if (this.isTerminal) return false; + this.isTerminal = true; + this.terminalValue = value; + this.disposables.dispose(); + this.resolveCompletion(value); + return true; + } +} diff --git a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts new file mode 100644 index 000000000..c01d07945 --- /dev/null +++ b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts @@ -0,0 +1,794 @@ +import type { BootManifestV1 } from '../core/types'; + +import { DisposableStack, type DisposeCallback } from './disposable'; + +const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; +const RELEASE_ID = /^[0-9a-f]{64}$/; +const MAX_INTEGRATIONS = 16; +const MAX_KNOWN_INTEGRATIONS = 256; +const BOOT_DEADLINE_MS = 10_000; +const EMPTY_BINDING = Object.freeze({}); +const ABORTED = Symbol('aborted'); + +export type BootFailureReason = 'abi_mismatch' | 'bundle_partial'; +export type IntegrationRegistryState = + | 'collecting' + | 'preparing' + | 'activating' + | 'publishing' + | 'committed' + | 'failed' + | 'disposed'; + +export interface IntegrationBindings { + readonly config: unknown; + readonly interfaces: Readonly>; +} + +export interface IntegrationPrepareContext extends IntegrationBindings { + readonly signal: AbortSignal; + readonly onDispose: (callback: DisposeCallback) => void; +} + +export interface IntegrationActivationContext { + readonly signal: AbortSignal; + readonly onDispose: (callback: DisposeCallback) => void; + readonly afterCommit: (callback: () => void) => void; +} + +export interface CoreActivationContext { + readonly signal: AbortSignal; + readonly onDispose: (callback: DisposeCallback) => void; +} + +export interface PreparedIntegration { + readonly activate: (context: IntegrationActivationContext) => void; +} + +export interface IntegrationRegistration { + readonly id: string; + readonly release: string; + readonly prepare: ( + context: IntegrationPrepareContext + ) => PreparedIntegration | PromiseLike; +} + +export interface IntegrationRuntimeFailure { + readonly id: string; + readonly phase: 'after_commit'; +} + +export interface IntegrationInstallCallbacks { + /** Installs reversible core listeners before any integration module activation. */ + readonly activateCore: (context: CoreActivationContext) => void; + /** Installs the complete API synchronously. It must not yield or invoke publisher code. */ + readonly publish: () => void; + /** Drains the already-committed preload queue. Callback isolation belongs to the queue. */ + readonly drainPreload: () => void; +} + +export interface IntegrationKernelResult { + readonly state: 'kernel'; + readonly runtimeFailures: readonly IntegrationRuntimeFailure[]; + readonly dispose: () => void; +} + +export interface IntegrationFallbackResult { + readonly state: 'fallback'; + readonly reason: BootFailureReason; +} + +export type IntegrationInstallResult = IntegrationKernelResult | IntegrationFallbackResult; + +export interface IntegrationRegistryOptions { + readonly manifest: unknown; + readonly releaseId: string; + /** Frozen build/composition inventory of integration ids this core release knows. */ + readonly knownIntegrationIds: readonly string[]; + readonly startedAtMs: number; + readonly now?: () => number; + readonly signal?: AbortSignal; + readonly getBindings?: (id: string) => IntegrationBindings; + readonly onDisposalError?: (error: unknown) => void; + readonly onRuntimeFailure?: (failure: IntegrationRuntimeFailure) => void; +} + +export interface IntegrationRegistry { + readonly state: IntegrationRegistryState; + readonly manifest: BootManifestV1 | undefined; + readonly register: (candidate: unknown) => boolean; + readonly install: (callbacks: IntegrationInstallCallbacks) => Promise; + readonly dispose: () => void; +} + +interface PreparedRecord { + readonly id: string; + readonly scope: DisposableStack; + readonly module: PreparedIntegration; + afterCommit: (() => void) | undefined; +} + +function isRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value) as unknown; + return prototype === Object.prototype || prototype === null; +} + +function readExactDataFields( + value: unknown, + expected: readonly string[] +): Readonly> | undefined { + if (!isRecord(value)) return undefined; + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expected.length || + !keys.every((key) => typeof key === 'string' && expected.includes(key)) + ) { + return undefined; + } + + const fields: Record = Object.create(null) as Record; + for (const key of expected) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor)) return undefined; + fields[key] = descriptor.value; + } + return fields; +} + +function snapshotExactArray(value: unknown, maximumLength: number): readonly unknown[] | undefined { + if (!Array.isArray(value) || value.length > maximumLength) return undefined; + const expectedKeys = Array.from({ length: value.length }, (_, index) => String(index)); + expectedKeys.push('length'); + const actualKeys = Reflect.ownKeys(value); + if ( + actualKeys.length !== expectedKeys.length || + !actualKeys.every((key) => typeof key === 'string' && expectedKeys.includes(key)) + ) { + return undefined; + } + + const snapshot: unknown[] = []; + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !('value' in descriptor)) return undefined; + snapshot.push(descriptor.value); + } + return Object.freeze(snapshot); +} + +function validateKnownIntegrationIds(candidate: unknown): ReadonlySet | undefined { + if (!Object.isFrozen(candidate)) return undefined; + const ids = snapshotExactArray(candidate, MAX_KNOWN_INTEGRATIONS); + if (!ids) return undefined; + + const known = new Set(); + for (const id of ids) { + if (typeof id !== 'string' || !INTEGRATION_ID.test(id) || known.has(id)) return undefined; + known.add(id); + } + return known; +} + +function validateManifest( + candidate: unknown, + embeddedReleaseId: string, + knownIntegrationIds: ReadonlySet +): BootManifestV1 | undefined { + try { + if (!RELEASE_ID.test(embeddedReleaseId)) return undefined; + const manifestFields = readExactDataFields(candidate, ['version', 'releaseId', 'integrations']); + if (!manifestFields) return undefined; + if (manifestFields.version !== 1 || manifestFields.releaseId !== embeddedReleaseId) { + return undefined; + } + const manifestIntegrations = snapshotExactArray(manifestFields.integrations, MAX_INTEGRATIONS); + if (!manifestIntegrations) return undefined; + + const seen = new Set(); + const integrations: { readonly id: string; readonly required: true }[] = []; + for (const entry of manifestIntegrations) { + const entryFields = readExactDataFields(entry, ['id', 'required']); + if (!entryFields) return undefined; + if (typeof entryFields.id !== 'string' || !INTEGRATION_ID.test(entryFields.id)) { + return undefined; + } + if (!knownIntegrationIds.has(entryFields.id)) return undefined; + if (entryFields.required !== true || seen.has(entryFields.id)) return undefined; + seen.add(entryFields.id); + integrations.push(Object.freeze({ id: entryFields.id, required: true })); + } + + return Object.freeze({ + version: 1, + releaseId: embeddedReleaseId, + integrations: Object.freeze(integrations), + }); + } catch { + return undefined; + } +} + +function isFrozenBinding(value: unknown): boolean { + return ( + value === null || + (typeof value !== 'object' && typeof value !== 'function') || + Object.isFrozen(value) + ); +} + +function hasOnlyFrozenDataValues(value: Record): boolean { + return Reflect.ownKeys(value).every((key) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && 'value' in descriptor && isFrozenBinding(descriptor.value); + }); +} + +function isThenable(value: unknown): value is PromiseLike { + return ( + (typeof value === 'object' || typeof value === 'function') && + value !== null && + typeof (value as { then?: unknown }).then === 'function' + ); +} + +function observeThenableRejection( + value: PromiseLike, + onRejected: (error: unknown) => void = () => undefined +): void { + try { + void Promise.resolve(value).catch((error: unknown) => { + try { + onRejected(error); + } catch { + // Rejection observation must never create another unhandled rejection. + } + }); + } catch (error) { + try { + onRejected(error); + } catch { + // Rejection observation is bounded and never changes registry control flow. + } + } +} + +class IntegrationRegistryOwner { + private readonly manifestValue: BootManifestV1 | undefined; + private readonly registrations = new Map(); + private readonly prepared: PreparedRecord[] = []; + private readonly abortController = new AbortController(); + private readonly coreScope: DisposableStack; + private readonly now: () => number; + private readonly getBindings: (id: string) => IntegrationBindings; + private readonly startedAtMs: number; + private readonly releaseId: string; + private readonly ownerSignal: AbortSignal | undefined; + private readonly onDisposalError: (error: unknown) => void; + private readonly onRuntimeFailure: (failure: IntegrationRuntimeFailure) => void; + private deadlineTimer: ReturnType | undefined; + private failureReason: BootFailureReason | undefined; + private installPromise: Promise | undefined; + private registryState: IntegrationRegistryState = 'collecting'; + private ownedCallbackDepth = 0; + private unwindPending = false; + + public constructor(options: IntegrationRegistryOptions) { + this.releaseId = options.releaseId; + this.startedAtMs = options.startedAtMs; + this.now = options.now ?? (() => performance.now()); + this.getBindings = + options.getBindings ?? + (() => ({ + config: EMPTY_BINDING, + interfaces: EMPTY_BINDING, + })); + this.ownerSignal = options.signal; + this.onDisposalError = options.onDisposalError ?? (() => undefined); + this.onRuntimeFailure = options.onRuntimeFailure ?? (() => undefined); + this.coreScope = new DisposableStack(this.onDisposalError); + const knownIntegrationIds = validateKnownIntegrationIds(options.knownIntegrationIds); + this.manifestValue = knownIntegrationIds + ? validateManifest(options.manifest, options.releaseId, knownIntegrationIds) + : undefined; + + if (!this.manifestValue) { + this.fail('abi_mismatch'); + return; + } + if (!Number.isFinite(this.startedAtMs) || this.deadlineExpired()) { + this.fail('bundle_partial'); + return; + } + if (this.ownerSignal?.aborted) { + this.fail('bundle_partial'); + return; + } + + this.ownerSignal?.addEventListener('abort', this.onOwnerAbort, { once: true }); + const remaining = Math.max(0, BOOT_DEADLINE_MS - (this.now() - this.startedAtMs)); + this.deadlineTimer = setTimeout(() => this.fail('bundle_partial'), remaining); + } + + public get state(): IntegrationRegistryState { + return this.registryState; + } + + public get manifest(): BootManifestV1 | undefined { + return this.manifestValue; + } + + public register(candidate: unknown): boolean { + if (this.registryState === 'preparing' || this.registryState === 'activating') { + this.fail('abi_mismatch'); + return false; + } + if (this.registryState !== 'collecting') return false; + if (this.deadlineExpired()) { + this.fail('bundle_partial'); + return false; + } + try { + const fields = readExactDataFields(candidate, ['id', 'release', 'prepare']); + if (!fields) { + this.fail('abi_mismatch'); + return false; + } + const { id, release, prepare } = fields; + if ( + typeof id !== 'string' || + !INTEGRATION_ID.test(id) || + typeof release !== 'string' || + release !== this.releaseId || + typeof prepare !== 'function' || + !this.manifestValue?.integrations.some((entry) => entry.id === id) || + this.registrations.has(id) + ) { + this.fail('abi_mismatch'); + return false; + } + + if (this.registryState !== 'collecting') return false; + + this.registrations.set( + id, + Object.freeze({ + id, + release, + prepare: prepare as IntegrationRegistration['prepare'], + }) + ); + return true; + } catch { + this.fail('abi_mismatch'); + return false; + } + } + + public install(callbacks: IntegrationInstallCallbacks): Promise { + if (this.installPromise) return this.installPromise; + + let resolveInstall: ((result: IntegrationInstallResult) => void) | undefined; + this.installPromise = new Promise((resolve) => { + resolveInstall = resolve; + }); + + let acceptedCallbacks: IntegrationInstallCallbacks; + try { + acceptedCallbacks = Object.freeze({ + activateCore: callbacks.activateCore, + publish: callbacks.publish, + drainPreload: callbacks.drainPreload, + }); + } catch { + this.fail('bundle_partial'); + resolveInstall?.(this.fallbackResult()); + return this.installPromise; + } + + void this.installTransaction(acceptedCallbacks).then( + (result) => resolveInstall?.(result), + () => { + this.fail('bundle_partial'); + resolveInstall?.(this.fallbackResult()); + } + ); + return this.installPromise; + } + + public dispose(): void { + if (this.registryState === 'disposed') return; + if (this.registryState !== 'failed') this.registryState = 'disposed'; + this.abortController.abort(); + this.clearBootOwnership(); + this.requestUnwind(); + } + + private readonly onOwnerAbort = (): void => { + this.fail('bundle_partial'); + }; + + private deadlineExpired(): boolean { + const elapsed = this.now() - this.startedAtMs; + return !Number.isFinite(elapsed) || elapsed >= BOOT_DEADLINE_MS; + } + + private fail(reason: BootFailureReason): void { + if ( + this.registryState === 'committed' || + this.registryState === 'failed' || + this.registryState === 'disposed' + ) { + return; + } + this.failureReason = reason; + this.registryState = 'failed'; + this.abortController.abort(); + this.clearBootOwnership(); + this.requestUnwind(); + } + + private enterOwnedCallback(): void { + this.ownedCallbackDepth += 1; + } + + private leaveOwnedCallback(): void { + this.ownedCallbackDepth -= 1; + if (this.ownedCallbackDepth === 0 && this.unwindPending) { + this.unwindPending = false; + this.disposePrepared(); + } + } + + private requestUnwind(): void { + if (this.ownedCallbackDepth > 0) { + this.unwindPending = true; + return; + } + this.disposePrepared(); + } + + private clearBootOwnership(): void { + if (this.deadlineTimer !== undefined) { + clearTimeout(this.deadlineTimer); + this.deadlineTimer = undefined; + } + this.ownerSignal?.removeEventListener('abort', this.onOwnerAbort); + } + + private disposePrepared(): void { + for (let index = this.prepared.length - 1; index >= 0; index -= 1) { + this.prepared[index]?.scope.dispose(); + } + this.coreScope.dispose(); + } + + private fallbackResult(): IntegrationFallbackResult { + return Object.freeze({ + state: 'fallback', + reason: this.failureReason ?? 'bundle_partial', + }); + } + + private async awaitPreparation( + promise: PromiseLike + ): Promise { + const observed = Promise.resolve(promise); + if (this.abortController.signal.aborted) { + observeThenableRejection(observed); + return ABORTED; + } + + let removeAbortListener: () => void = () => undefined; + const aborted = new Promise((resolve) => { + const onAbort = () => resolve(ABORTED); + this.abortController.signal.addEventListener('abort', onAbort, { once: true }); + removeAbortListener = () => this.abortController.signal.removeEventListener('abort', onAbort); + }); + + try { + return await Promise.race([observed, aborted]); + } finally { + removeAbortListener(); + } + } + + private createPreparationContext( + id: string, + scope: DisposableStack + ): { readonly context: IntegrationPrepareContext; readonly close: () => void } { + const bindings = this.getBindings(id); + const fields = readExactDataFields(bindings, ['config', 'interfaces']); + if ( + !fields || + !isFrozenBinding(fields.config) || + !isRecord(fields.interfaces) || + !Object.isFrozen(fields.interfaces) || + !hasOnlyFrozenDataValues(fields.interfaces) + ) { + throw new TypeError('Integration bindings must expose exact frozen values'); + } + + let open = true; + const context: IntegrationPrepareContext = Object.freeze({ + config: fields.config, + interfaces: fields.interfaces, + signal: this.abortController.signal, + onDispose: (callback: DisposeCallback) => { + if (!open && !scope.disposed) { + throw new Error('Preparation disposal registration is closed'); + } + scope.onDispose(callback); + }, + }); + return Object.freeze({ + context, + close: () => { + open = false; + }, + }); + } + + private async installTransaction( + callbacks: IntegrationInstallCallbacks + ): Promise { + if (this.registryState === 'failed' || this.registryState === 'disposed') { + return this.fallbackResult(); + } + if ( + !this.manifestValue || + this.deadlineExpired() || + this.manifestValue.integrations.some((entry) => !this.registrations.has(entry.id)) + ) { + this.fail('bundle_partial'); + return this.fallbackResult(); + } + + this.registryState = 'preparing'; + for (const entry of this.manifestValue.integrations) { + if (this.registryState !== 'preparing' || this.deadlineExpired()) { + this.fail('bundle_partial'); + return this.fallbackResult(); + } + + const scope = new DisposableStack(this.onDisposalError); + const registration = this.registrations.get(entry.id); + if (!registration) { + this.fail('bundle_partial'); + return this.fallbackResult(); + } + + this.prepared.push({ + id: entry.id, + scope, + module: Object.freeze({ activate: () => undefined }), + afterCommit: undefined, + }); + const recordIndex = this.prepared.length - 1; + + try { + const { context, close } = this.createPreparationContext(entry.id, scope); + if (this.registryState !== 'preparing') { + close(); + return this.fallbackResult(); + } + let pending: PreparedIntegration | PromiseLike; + this.enterOwnedCallback(); + try { + pending = registration.prepare(context); + } finally { + this.leaveOwnedCallback(); + } + let prepared: PreparedIntegration | typeof ABORTED; + if (isThenable(pending)) { + prepared = await this.awaitPreparation(pending as PromiseLike); + close(); + } else { + close(); + prepared = pending; + } + if (prepared === ABORTED || this.registryState !== 'preparing') { + this.fail('bundle_partial'); + return this.fallbackResult(); + } + const preparedFields = readExactDataFields(prepared, ['activate']); + if (!preparedFields || typeof preparedFields.activate !== 'function') { + throw new TypeError('prepare must return one exact activation module'); + } + if (this.registryState !== 'preparing') return this.fallbackResult(); + this.prepared[recordIndex] = { + id: entry.id, + scope, + module: Object.freeze({ + activate: preparedFields.activate as PreparedIntegration['activate'], + }), + afterCommit: undefined, + }; + } catch { + this.fail('bundle_partial'); + return this.fallbackResult(); + } + + if (this.registryState !== 'preparing' || this.deadlineExpired()) { + this.fail('bundle_partial'); + return this.fallbackResult(); + } + } + + if (this.registryState !== 'preparing') return this.fallbackResult(); + this.registryState = 'activating'; + if (this.deadlineExpired()) { + this.fail('bundle_partial'); + return this.fallbackResult(); + } + + let coreActivationOpen = true; + const coreContext: CoreActivationContext = Object.freeze({ + signal: this.abortController.signal, + onDispose: (callback: DisposeCallback) => { + if (!coreActivationOpen && !this.coreScope.disposed) { + throw new Error('Core activation disposal registration is closed'); + } + this.coreScope.onDispose(callback); + }, + }); + this.enterOwnedCallback(); + try { + const returned = callbacks.activateCore(coreContext); + if (isThenable(returned)) { + observeThenableRejection(returned); + throw new TypeError('Core activation must be synchronous'); + } + } catch { + this.fail('bundle_partial'); + return this.fallbackResult(); + } finally { + coreActivationOpen = false; + this.leaveOwnedCallback(); + } + + if (this.registryState !== 'activating' || this.deadlineExpired()) { + this.fail('bundle_partial'); + return this.fallbackResult(); + } + + for (const record of this.prepared) { + if (this.registryState !== 'activating' || this.deadlineExpired()) { + this.fail('bundle_partial'); + return this.fallbackResult(); + } + + let activationOpen = true; + let afterCommitRegistered = false; + let activationInvalid = false; + const context: IntegrationActivationContext = Object.freeze({ + signal: this.abortController.signal, + onDispose: (callback: DisposeCallback) => { + if (!activationOpen && !record.scope.disposed) { + throw new Error('Activation disposal registration is closed'); + } + record.scope.onDispose(callback); + }, + afterCommit: (callback: () => void) => { + if (!activationOpen) throw new Error('Activation is closed'); + if (typeof callback !== 'function') throw new TypeError('afterCommit must be a function'); + if (afterCommitRegistered) { + activationInvalid = true; + throw new Error('afterCommit may be registered only once'); + } + afterCommitRegistered = true; + record.afterCommit = callback; + }, + }); + + this.enterOwnedCallback(); + try { + const returned = record.module.activate(context); + if (isThenable(returned)) { + observeThenableRejection(returned); + throw new TypeError('Integration activation must be synchronous'); + } + if (activationInvalid) { + throw new Error('Integration activation violated the afterCommit contract'); + } + } catch { + this.fail('bundle_partial'); + return this.fallbackResult(); + } finally { + activationOpen = false; + this.leaveOwnedCallback(); + } + + if (this.registryState !== 'activating' || this.deadlineExpired()) { + this.fail('bundle_partial'); + return this.fallbackResult(); + } + } + + // This final monotonic check closes the timer-task delay gap. A same-thread + // activation that never returns cannot be preempted by JavaScript. + if (this.registryState !== 'activating' || this.deadlineExpired()) { + this.fail('bundle_partial'); + return this.fallbackResult(); + } + + this.registryState = 'publishing'; + this.enterOwnedCallback(); + try { + const published = callbacks.publish(); + if (isThenable(published)) { + observeThenableRejection(published); + throw new TypeError('Kernel publication must be synchronous'); + } + } catch { + this.fail('bundle_partial'); + return this.fallbackResult(); + } finally { + this.leaveOwnedCallback(); + } + + if (this.registryState !== 'publishing') { + this.fail('bundle_partial'); + return this.fallbackResult(); + } + + this.registryState = 'committed'; + this.clearBootOwnership(); + const runtimeFailures: IntegrationRuntimeFailure[] = []; + for (const record of this.prepared) { + if (!record.afterCommit) continue; + try { + record.afterCommit(); + } catch { + record.scope.dispose(); + const failure = Object.freeze({ id: record.id, phase: 'after_commit' as const }); + runtimeFailures.push(failure); + try { + this.onRuntimeFailure(failure); + } catch { + // Runtime failure reporting is bounded observation, never control flow. + } + } + } + + try { + const drained = callbacks.drainPreload(); + if (isThenable(drained)) { + observeThenableRejection(drained, (error) => this.reportDisposalError(error)); + } + } catch (error) { + this.reportDisposalError(error); + } + + return Object.freeze({ + state: 'kernel', + runtimeFailures: Object.freeze(runtimeFailures), + dispose: () => this.dispose(), + }); + } + + private reportDisposalError(error: unknown): void { + try { + this.onDisposalError(error); + } catch { + // The queue owns per-callback isolation; an observer cannot undo commit. + } + } +} + +export function createIntegrationRegistry( + options: IntegrationRegistryOptions +): IntegrationRegistry { + const owner = new IntegrationRegistryOwner(options); + return Object.freeze({ + get state() { + return owner.state; + }, + get manifest() { + return owner.manifest; + }, + register: (candidate: unknown) => owner.register(candidate), + install: (callbacks: IntegrationInstallCallbacks) => owner.install(callbacks), + dispose: () => owner.dispose(), + }); +} 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/integration_registry.test.ts b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts new file mode 100644 index 000000000..683ca1e4f --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts @@ -0,0 +1,1237 @@ +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) { + return createIntegrationRegistryOwner({ + ...options, + knownIntegrationIds: options.knownIntegrationIds ?? manifestIds(options.manifest), + }); +} + +function manifest(ids: readonly string[]): BootManifestV1 { + return { + version: 1, + releaseId: RELEASE_ID, + integrations: ids.map((id) => ({ id, required: true as const })), + }; +} + +function registration( + id: string, + hooks: Partial = {} +): IntegrationRegistration { + return { + id, + release: 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(); +}); + +describe('integration manifest and registration admission', () => { + 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', + '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 < 17; index += 1) { + yield { id: `module_${index}`, required: true }; + } + }); + 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: 17 }, (_, 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', { release: 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('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 = { + id: 'gpt', + release: 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.release = OTHER_RELEASE_ID; + candidate.prepare = swappedPrepare; + + await expect(install(registry)).resolves.toMatchObject({ state: 'kernel' }); + expect(acceptedPrepare).toHaveBeenCalledTimes(1); + expect(swappedPrepare).not.toHaveBeenCalled(); + }); + + it('fails missing required modules without executing registered module code', async () => { + const prepare = vi.fn(() => ({ activate: () => undefined })); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register(registration('gpt', { prepare })); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(prepare).not.toHaveBeenCalled(); + }); + + it('accepts exactly 16 required modules in manifest order', async () => { + const ids = Array.from({ length: 16 }, (_, 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, 16)).toEqual(ids.map((id) => `prepare:${id}`)); + expect(order.slice(16, 32)).toEqual(ids.map((id) => `activate:${id}`)); + expect(order.slice(32)).toEqual(['publish', 'drain']); + }); +}); + +describe('integration preparation and activation transaction', () => { + 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: () => 0, + }); + 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(); + }); +}); From 3a5042d828029ec7e92ec1505cb5ab5af8226311 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:50:41 -0700 Subject: [PATCH 023/194] Upgrade the TSJS package and TypeScript toolchain --- crates/trusted-server-js/lib/build-all.mjs | 1 - .../lib/build-prebid-external.mjs | 4 +- crates/trusted-server-js/lib/eslint.config.js | 22 +- .../trusted-server-js/lib/package-lock.json | 5635 ++++++----------- crates/trusted-server-js/lib/package.json | 33 +- crates/trusted-server-js/lib/src/core/log.ts | 5 +- .../trusted-server-js/lib/src/core/request.ts | 7 +- .../trusted-server-js/lib/src/core/types.ts | 3 +- .../creative/dynamic_src_guard.ts | 3 +- .../lib/src/integrations/gpt/index.ts | 3 +- .../lib/src/kernel/integration_registry.ts | 8 +- .../test/eslint/no-adtech-globals.test.mjs | 6 +- .../lib/test/integrations/aps/render.test.ts | 5 +- .../test/prebid-artifact-integration.test.mjs | 1 - crates/trusted-server-js/lib/vitest.config.ts | 4 +- 15 files changed, 2074 insertions(+), 3666 deletions(-) diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 17b12c11d..8f7a3e897 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -96,7 +96,6 @@ async function buildModule(name, entryPath) { format: 'iife', dir: distDir, entryFileNames: outFile, - inlineDynamicImports: true, extend: false, // Use a unique IIFE name per module to avoid conflicts name: name === 'core' ? 'tsjs' : `tsjs_${name}`, diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index eb6e42826..0718bdeee 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -102,7 +102,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 } ); } } @@ -337,7 +338,6 @@ async function buildExternalBundle(outDir, generatedModules) { format: 'iife', dir: outDir, entryFileNames: temporaryFile, - inlineDynamicImports: true, extend: false, name: 'tsjs_prebid_external', }, diff --git a/crates/trusted-server-js/lib/eslint.config.js b/crates/trusted-server-js/lib/eslint.config.js index efbaed5fe..b1403b1cc 100644 --- a/crates/trusted-server-js/lib/eslint.config.js +++ b/crates/trusted-server-js/lib/eslint.config.js @@ -1,8 +1,9 @@ -// 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'; @@ -87,14 +88,11 @@ export default [ { files: ['**/*.ts', '**/*.tsx'], settings: { - 'import/resolver': { - typescript: { + 'import-x/resolver-next': [ + createTypeScriptImportResolver({ project: './tsconfig.json', - }, - node: { - extensions: ['.js', '.mjs', '.ts', '.tsx'], - }, - }, + }), + ], }, languageOptions: { parser: tseslint.parser, @@ -104,7 +102,7 @@ export default [ }, }, plugins: { - import: importPlugin, + 'import-x': importX, jsdoc, tsjs: { rules: { @@ -117,7 +115,7 @@ export default [ rules: { 'unicorn/prevent-abbreviations': 'off', 'unicorn/filename-case': 'off', - 'import/order': ['error', { 'newlines-between': 'always' }], + 'import-x/order': ['error', { 'newlines-between': 'always' }], }, }, // New architecture paths are clean by default. These exact legacy files are @@ -138,7 +136,7 @@ export default [ files: ['src/**/*.ts', 'src/**/*.tsx'], ignores: LEGACY_RESTRICTED_IMPORT_ALLOWLIST, rules: { - 'import/no-restricted-paths': [ + 'import-x/no-restricted-paths': [ 'error', { basePath: import.meta.dirname, diff --git a/crates/trusted-server-js/lib/package-lock.json b/crates/trusted-server-js/lib/package-lock.json index 13fa70458..f27068f59 100644 --- a/crates/trusted-server-js/lib/package-lock.json +++ b/crates/trusted-server-js/lib/package-lock.json @@ -8,62 +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-import-resolver-typescript": "^4.4.5", - "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-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": { @@ -74,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" }, @@ -88,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", @@ -137,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" @@ -153,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" @@ -199,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": { @@ -229,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" }, @@ -255,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", @@ -271,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" @@ -323,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" @@ -361,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" @@ -378,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" @@ -460,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" @@ -476,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" @@ -491,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" @@ -506,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" @@ -523,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" @@ -551,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" @@ -566,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" @@ -581,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" @@ -596,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" @@ -627,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" @@ -642,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" @@ -659,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" @@ -676,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" @@ -691,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" @@ -706,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" @@ -722,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" @@ -738,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" @@ -758,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" @@ -774,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" @@ -790,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" @@ -806,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" @@ -821,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" @@ -837,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" @@ -852,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" @@ -868,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" @@ -883,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" @@ -898,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" @@ -914,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" @@ -931,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" @@ -946,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" @@ -961,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" @@ -976,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" @@ -991,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" @@ -1007,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" @@ -1023,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" @@ -1041,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" @@ -1057,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" @@ -1073,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" @@ -1088,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" @@ -1103,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" @@ -1118,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" @@ -1137,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" @@ -1153,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" @@ -1168,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" @@ -1184,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" @@ -1199,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" @@ -1215,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" @@ -1232,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" @@ -1247,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" @@ -1262,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" @@ -1278,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" @@ -1293,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" @@ -1308,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" @@ -1324,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" @@ -1339,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" @@ -1354,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" @@ -1369,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" @@ -1388,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" @@ -1403,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" @@ -1419,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" @@ -1435,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" @@ -1451,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", @@ -1558,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" @@ -1577,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": { @@ -1618,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" @@ -1644,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": [ { @@ -1664,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": [ { @@ -1688,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": [ { @@ -1704,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" @@ -1740,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": [ { @@ -1754,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", @@ -1777,10 +1810,35 @@ "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.3", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", - "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "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, @@ -1789,17 +1847,17 @@ } }, "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" @@ -1816,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" ], @@ -1833,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" ], @@ -1850,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" ], @@ -1867,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" ], @@ -1884,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" ], @@ -1901,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" ], @@ -1918,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" ], @@ -1935,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" ], @@ -1952,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" ], @@ -1969,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" ], @@ -1986,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" ], @@ -2003,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" ], @@ -2020,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" ], @@ -2037,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" ], @@ -2054,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" ], @@ -2071,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" ], @@ -2088,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" ], @@ -2105,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" ], @@ -2122,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" ], @@ -2139,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" ], @@ -2156,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" ], @@ -2173,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" ], @@ -2190,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" ], @@ -2207,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" ], @@ -2224,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" ], @@ -2241,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" ], @@ -2258,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": { @@ -2276,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", @@ -2287,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": { @@ -2478,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", @@ -2596,24 +2608,20 @@ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, - "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/@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", - "optional": true, - "os": [ - "android" - ] + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", "cpu": [ "arm64" ], @@ -2622,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" ], @@ -2636,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" ], @@ -2650,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" ], @@ -2678,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" ], @@ -2692,26 +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" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "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" ], @@ -2720,68 +2732,15 @@ "optional": true, "os": [ "linux" - ] - }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "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" ], @@ -2790,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" ], @@ -2832,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" ], @@ -2846,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" ], @@ -2860,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" ], @@ -2888,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" ], @@ -2902,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" ], @@ -2930,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" }, @@ -3002,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": { @@ -3027,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", @@ -3052,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" @@ -3075,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": { @@ -3103,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": { @@ -3125,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" @@ -3147,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": { @@ -3160,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" @@ -3185,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": { @@ -3203,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" @@ -3227,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" @@ -3251,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": { @@ -3272,19 +3201,6 @@ "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/@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", @@ -3556,40 +3472,6 @@ "node": ">=14.0.0" } }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/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/@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", @@ -3633,31 +3515,31 @@ ] }, "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "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.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@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.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "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.0.18", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3666,7 +3548,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -3678,26 +3560,26 @@ } }, "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==", + "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.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "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==", + "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.0.18", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -3705,13 +3587,14 @@ } }, "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==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3720,9 +3603,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -3730,14 +3613,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3757,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, @@ -3780,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": { @@ -3825,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", @@ -3858,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", @@ -3894,11 +3752,13 @@ } }, "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } }, "node_modules/arr-diff": { "version": "4.0.0", @@ -3918,187 +3778,39 @@ "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==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "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": ">=12" } }, - "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, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", "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", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/assign-symbols": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", - "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", - "license": "MIT", - "engines": { - "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": ">=0.10.0" } }, "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": { @@ -4115,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": { @@ -4128,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" @@ -4175,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", @@ -4188,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" @@ -4214,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", @@ -4257,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" @@ -4281,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", @@ -4306,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": { @@ -4327,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", @@ -4375,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", @@ -4415,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", @@ -4455,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", @@ -4549,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", @@ -4571,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", @@ -4619,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", @@ -4665,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", @@ -4750,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", @@ -4805,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/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "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": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" + "license": "MIT", + "engines": { + "node": ">=12.20" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=0.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", @@ -4854,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": { @@ -4869,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": { @@ -4881,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", @@ -4969,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" @@ -4987,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", @@ -5041,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" }, @@ -5054,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": { @@ -5111,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", @@ -5148,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" }, @@ -5157,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" @@ -5212,28 +4630,6 @@ } } }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, "node_modules/eslint-import-resolver-typescript": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", @@ -5269,312 +4665,156 @@ } } }, - "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" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "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-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": { - "@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", + "@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": "^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" + "minimatch": "^9.0.3 || ^10.1.2", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" }, "engines": { - "node": ">=4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-import-x" }, "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" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint-import-resolver-node": "*" }, - "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" + "peerDependenciesMeta": { + "@typescript-eslint/utils": { + "optional": true + }, + "eslint-import-resolver-node": { + "optional": true + } } }, "node_modules/eslint-plugin-jsdoc": { - "version": "62.6.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.6.1.tgz", - "integrity": "sha512-zfz4lMIKDkidkqZniIieZujwZAtpaSNM0WXwilToKoR2UWEw0JE/QevQI2k6YN4ZSy3YhXB3Vs1ab62GZu8Wug==", + "version": "63.3.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-63.3.3.tgz", + "integrity": "sha512-xI4IeVRzRFA2DGHrPLIxF3U+oJHU3FE+P9Zb27fVs5dPHgfcpoAs0PyCbznVhK7pwR+9BPUztFeXSgpw/CL4Yg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@es-joy/jsdoccomment": "~0.84.0", + "@es-joy/jsdoccomment": "~0.91.0", "@es-joy/resolve.exports": "1.2.0", "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.5", + "comment-parser": "1.4.7", "debug": "^4.4.3", "escape-string-regexp": "^4.0.0", - "espree": "^11.1.0", + "espree": "^11.2.0", "esquery": "^1.7.0", "html-entities": "^2.6.0", - "object-deep-merge": "^2.0.0", + "object-deep-merge": "^2.0.1", "parse-imports-exports": "^0.2.4", - "semver": "^7.7.3", - "spdx-expression-parse": "^4.0.0", + "semver": "^7.8.5", + "spdx-expression-parse": "^5.0.0", "to-valid-identifier": "^1.0.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^22.13.0 || >=24" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-jsdoc/node_modules/eslint-visitor-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-jsdoc/node_modules/espree": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.0.tgz", - "integrity": "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-plugin-unicorn": { - "version": "62.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-62.0.0.tgz", - "integrity": "sha512-HIlIkGLkvf29YEiS/ImuDZQbP12gWyx5i3C6XrRxMvVdqMroCI9qoVYCoIl17ChN+U89pn9sVwLxhIWj5nEc7g==", + "version": "73.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-73.0.0.tgz", + "integrity": "sha512-V0YatLe9nkGhXEXKe2Qljb1EY0sJHwDV0HUF1NKFwtsHh/fU7qGHDgv+6fchzZcgU2/7noHo2gdjnmo0P2uDPw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "@eslint-community/eslint-utils": "^4.9.0", - "@eslint/plugin-kit": "^0.4.0", + "@eslint-community/eslint-utils": "^4.9.1", + "@eslint/css-tree": "^4.0.4", + "browserslist": "^4.28.4", "change-case": "^5.4.4", - "ci-info": "^4.3.1", - "clean-regexp": "^1.0.0", - "core-js-compat": "^3.46.0", - "esquery": "^1.6.0", + "ci-info": "^4.4.0", + "core-js-compat": "^3.49.0", + "detect-indent": "^7.0.2", + "entities": "^4.5.0", "find-up-simple": "^1.0.1", - "globals": "^16.4.0", + "globals": "^17.7.0", "indent-string": "^5.0.0", "is-builtin-module": "^5.0.0", - "jsesc": "^3.1.0", + "is-identifier": "^1.1.0", "pluralize": "^8.0.0", - "regexp-tree": "^0.1.27", - "regjsparser": "^0.13.0", - "semver": "^7.7.3", - "strip-indent": "^4.1.1" + "quote-js-string": "^0.1.0", + "regjsparser": "^0.13.2", + "reserved-identifiers": "^1.2.0", + "semver": "^7.8.5", + "strip-indent": "^4.1.1", + "yaml": "^2.9.0" }, "engines": { - "node": "^20.10.0 || >=21.0.0" + "node": ">=22" }, "funding": { "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" }, "peerDependencies": { - "eslint": ">=9.38.0" + "eslint": ">=10.4" } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "eslint-visitor-keys": "^5.0.1" }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -5658,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": { @@ -5668,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", @@ -5694,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", @@ -5762,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", @@ -5886,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", @@ -5967,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": { @@ -6054,24 +5250,6 @@ "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-tsconfig": { "version": "4.14.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.1.tgz", @@ -6099,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": { @@ -6111,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", @@ -6147,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": { @@ -6185,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", @@ -6226,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", @@ -6290,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" @@ -6368,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", @@ -6406,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" @@ -6438,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", @@ -6494,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", @@ -6527,77 +5544,6 @@ "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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==", - "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" - } - }, - "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, - "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", @@ -6624,61 +5570,13 @@ "semver": "^7.7.1" } }, - "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, + "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": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -6709,46 +5607,10 @@ "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", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -6758,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": { @@ -6820,156 +5656,10 @@ "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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, - "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-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_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==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_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==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "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, + "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": { @@ -6995,22 +5685,22 @@ "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, + "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": "^2.0.1" + "argparse": "^1.0.7", + "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "node_modules/jsdoc-type-pratt-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.1.1.tgz", - "integrity": "sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==", + "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": { @@ -7018,36 +5708,37 @@ } }, "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "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": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", + "@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", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", + "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.0", + "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -7058,19 +5749,6 @@ } } }, - "node_modules/jsdom/node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7149,6 +5827,267 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/live-connect-common": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/live-connect-common/-/live-connect-common-4.1.0.tgz", @@ -7188,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": { @@ -7199,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": { @@ -7226,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", @@ -7236,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" }, @@ -7303,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" @@ -7318,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", @@ -7335,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": [ { @@ -7386,10 +6326,13 @@ } }, "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" + "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/node.extend": { "version": "2.0.2", @@ -7404,102 +6347,18 @@ "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" - } - }, - "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", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "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" }, @@ -7508,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", @@ -7548,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": { @@ -7598,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": { @@ -7629,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", @@ -7677,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": { @@ -7696,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, @@ -7734,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": [ { @@ -7765,7 +6628,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7818,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": { @@ -7833,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", @@ -7863,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" @@ -7877,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", @@ -7902,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": { @@ -7969,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", @@ -8024,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" @@ -8067,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" @@ -8086,16 +6891,6 @@ "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -8106,69 +6901,37 @@ "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": { @@ -8191,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", @@ -8265,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": { @@ -8300,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": { @@ -8366,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", @@ -8445,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" }, @@ -8464,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" @@ -8550,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": { @@ -8561,147 +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==", - "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/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", - "engines": { - "node": ">=12.0.0" - } - }, - "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": "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/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/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/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": { @@ -8717,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", @@ -8763,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": { @@ -8786,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": { @@ -8796,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" @@ -8813,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": { @@ -8823,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" }, @@ -8869,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": { @@ -8901,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": { @@ -8913,32 +7511,6 @@ "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -8960,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", @@ -8973,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, @@ -9076,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" @@ -9096,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": { @@ -9114,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": { @@ -9144,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" }, @@ -9206,7 +7694,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.4" }, @@ -9320,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" @@ -9348,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", @@ -9363,13 +7850,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -9396,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": { @@ -9436,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": { @@ -9462,6 +7955,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -9470,6 +7969,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -9486,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", @@ -9537,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", @@ -9670,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 312cc7820..152ad30c6 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -20,26 +20,25 @@ "format:write": "prettier --write \"**/*.{ts,tsx,js,json,css,md}\"" }, "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-import-resolver-typescript": "^4.4.5", - "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-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/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/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index 4018ac5e8..6c41ea498 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -30,16 +30,13 @@ type RenderCreativeInlineOptions = { // Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. export function requestAds( callbackOrOpts?: RequestAdsCallback | RequestAdsOptions, - maybeOpts?: 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; + callback = (callbackOrOpts as RequestAdsOptions | undefined)?.bidsBackHandler; } log.info('requestAds: called', { hasCallback: typeof callback === 'function' }); diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 6a29e0bd0..f10300e18 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -442,8 +442,7 @@ export interface TsjsApi { * a minimal fallback for pages where the bundle fails to load. */ scheduleInitialAdInit?: - | ((initialBids?: Record | undefined) => void) - | undefined; + ((initialBids?: Record | undefined) => void) | undefined; /** Read-only GPT lifecycle diagnostics API, present only in an activated tab. */ gptDiagnostics?: GptDiagnosticsApi | undefined; } 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..0e8d4cb84 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 @@ -41,8 +41,7 @@ export function createDynamicSrcProxy( 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; + ((this: E, namespace: string | null, name: string, value: string) => void) | undefined; const wrappedInstances = new WeakSet(); let createElementPatched = false; let factoryPatched = false; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index ed81b6d53..22e28d5e8 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -588,8 +588,7 @@ function installInitialLoadDetector(ts: TsjsApi): void { if (!cmd) return; cmd.push(() => { const gpt = win.googletag as - | (Partial & { __tsInitialLoadConfigHooked?: boolean }) - | undefined; + (Partial & { __tsInitialLoadConfigHooked?: boolean }) | undefined; if (!gpt) return; syncInitialLoadDisabled(gpt, ts); diff --git a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts index c01d07945..65aff7c45 100644 --- a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts +++ b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts @@ -12,13 +12,7 @@ const ABORTED = Symbol('aborted'); export type BootFailureReason = 'abi_mismatch' | 'bundle_partial'; export type IntegrationRegistryState = - | 'collecting' - | 'preparing' - | 'activating' - | 'publishing' - | 'committed' - | 'failed' - | 'disposed'; + 'collecting' | 'preparing' | 'activating' | 'publishing' | 'committed' | 'failed' | 'disposed'; export interface IntegrationBindings { readonly config: unknown; 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 index d03467e68..14df05812 100644 --- 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 @@ -202,7 +202,7 @@ test('every temporary exemption still maps to an active legacy violation', async files: ['src/**/*.ts', 'src/**/*.tsx'], rules: { 'tsjs/no-adtech-globals': ['error', { allowFiles: [] }], - 'import/no-restricted-paths': [ + 'import-x/no-restricted-paths': [ 'error', { basePath: packageRoot, @@ -226,7 +226,7 @@ test('every temporary exemption still maps to an active legacy violation', async const [result] = await strictEslint.lintFiles([relativeFilename]); assert.ok(result); assert.ok( - result.messages.some((message) => message.ruleId === 'import/no-restricted-paths'), + result.messages.some((message) => message.ruleId === 'import-x/no-restricted-paths'), `${relativeFilename} no longer needs its restricted-import exemption` ); } @@ -234,7 +234,7 @@ test('every temporary exemption still maps to an active legacy violation', async test('restricted paths enforce dependency direction and exact target-file exemptions', async () => { const eslint = new ESLint({ cwd: packageRoot }); - const restrictedRuleId = 'import/no-restricted-paths'; + const restrictedRuleId = 'import-x/no-restricted-paths'; async function restrictedMessages(source, relativeFilename) { const [result] = await eslint.lintText(source, { 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 22ef77702..b09a37084 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 @@ -53,10 +53,7 @@ function descriptor(overrides: Partial = {}): ApsRendererV1 { } type CorpusResult = - | 'accepted' - | 'descriptor_invalid' - | 'invalid_dimensions' - | 'dimensions_out_of_range'; + 'accepted' | 'descriptor_invalid' | 'invalid_dimensions' | 'dimensions_out_of_range'; interface CorpusVector { id: string; 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..6582d9089 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 @@ -58,7 +58,6 @@ beforeAll(async () => { format: 'iife', dir: outputDirectory, entryFileNames: 'tsjs-prebid.js', - inlineDynamicImports: true, extend: false, name: 'tsjs_prebid', }, diff --git a/crates/trusted-server-js/lib/vitest.config.ts b/crates/trusted-server-js/lib/vitest.config.ts index c844ba815..7a6941faf 100644 --- a/crates/trusted-server-js/lib/vitest.config.ts +++ b/crates/trusted-server-js/lib/vitest.config.ts @@ -9,11 +9,11 @@ export default defineConfig({ // "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' ), }, From 4f0fb43fdebf5ff2a0d44ccda861f50a61c2d894 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:36:26 -0700 Subject: [PATCH 024/194] Establish TSJS runtime ownership and release identity --- .../src/integrations/gpt.rs | 25 + crates/trusted-server-core/src/publisher.rs | 3 + crates/trusted-server-core/src/tsjs.rs | 97 +- crates/trusted-server-js/Cargo.toml | 1 + crates/trusted-server-js/build.rs | 93 ++ crates/trusted-server-js/lib/.prettierignore | 2 +- crates/trusted-server-js/lib/build-all.mjs | 60 +- .../lib/eslint-rules/no-adtech-globals.js | 1 - crates/trusted-server-js/lib/eslint.config.js | 2 +- crates/trusted-server-js/lib/package.json | 7 +- .../lib/scripts/print-release-id.mjs | 75 + .../lib/scripts/release-v1.mjs | 46 + .../lib/src/composition/browser.ts | 43 + .../trusted-server-js/lib/src/core/auction.ts | 449 +----- .../lib/src/core/contracts/aps_renderer.ts | 53 + .../src/core/contracts/auction_projection.ts | 452 ++++++ .../generated/renderer_validator_v1.ts | 0 .../lib/src/core/global.d.ts | 1 + .../trusted-server-js/lib/src/core/index.ts | 2 + .../trusted-server-js/lib/src/core/queue.ts | 225 ++- .../trusted-server-js/lib/src/core/release.ts | 4 + .../trusted-server-js/lib/src/core/surface.ts | 30 + .../lib/src/integrations/aps/render.ts | 42 +- .../integrations/gpt/bootstrap_fallback.ts | 52 + .../lib/src/kernel/fallback.ts | 653 ++++++++ .../lib/src/kernel/integration_registry.ts | 73 +- .../lib/src/kernel/runtime.ts | 315 ++++ .../test/build/generated-fallback.test.mjs | 104 ++ .../lib/test/build/release-v1.test.mjs | 40 + .../lib/test/composition/browser.test.ts | 156 +- .../lib/test/core/log.test.ts | 60 + .../lib/test/core/queue.test.ts | 247 +++ .../test/eslint/no-adtech-globals.test.mjs | 55 +- .../lib/test/integrations/aps/render.test.ts | 2 +- .../integrations/gpt/gpt_bootstrap.test.ts | 104 ++ .../lib/test/kernel/runtime.test.ts | 1356 +++++++++++++++++ crates/trusted-server-js/lib/vitest.config.ts | 4 + crates/trusted-server-js/src/bundle.rs | 14 + crates/trusted-server-js/src/lib.rs | 3 +- scripts/generate-aps-renderer-contract.mjs | 2 +- 40 files changed, 4442 insertions(+), 511 deletions(-) create mode 100644 crates/trusted-server-js/lib/scripts/print-release-id.mjs create mode 100644 crates/trusted-server-js/lib/scripts/release-v1.mjs create mode 100644 crates/trusted-server-js/lib/src/core/contracts/aps_renderer.ts create mode 100644 crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts rename crates/trusted-server-js/lib/src/{integrations/aps => core/contracts}/generated/renderer_validator_v1.ts (100%) create mode 100644 crates/trusted-server-js/lib/src/core/release.ts create mode 100644 crates/trusted-server-js/lib/src/core/surface.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/gpt/bootstrap_fallback.ts create mode 100644 crates/trusted-server-js/lib/src/kernel/fallback.ts create mode 100644 crates/trusted-server-js/lib/src/kernel/runtime.ts create mode 100644 crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs create mode 100644 crates/trusted-server-js/lib/test/build/release-v1.test.mjs create mode 100644 crates/trusted-server-js/lib/test/core/log.test.ts create mode 100644 crates/trusted-server-js/lib/test/core/queue.test.ts create mode 100644 crates/trusted-server-js/lib/test/kernel/runtime.test.ts diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 3b3b40b78..ffa53f0a3 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -520,6 +520,11 @@ impl IntegrationHeadInjector for GptIntegration { /// publisher's own init code also calls `googletag.enableServices()`. const GPT_BOOTSTRAP_JS: &str = include_str!("gpt_bootstrap.js"); +#[cfg(test)] +fn proposed_gpt_bootstrap_fallback_js() -> &'static str { + trusted_server_js::gpt_bootstrap_fallback_bundle() +} + // Default value functions fn default_enabled() -> bool { @@ -1453,4 +1458,24 @@ mod tests { "should not emit slim-Prebid URL tag when not configured" ); } + + #[test] + fn proposed_generated_fallback_is_stamped_but_not_the_production_bootstrap() { + let release = trusted_server_js::release_id(); + let proposed = proposed_gpt_bootstrap_fallback_js(); + + assert_eq!( + proposed.matches(release).count(), + 1, + "generated proposal should carry the exact release once" + ); + assert!( + proposed.contains("runtime_unavailable"), + "generated proposal should contain the terminal fallback shell" + ); + assert!( + !GPT_BOOTSTRAP_JS.contains(release), + "Task 8 must not replace the production GPT bootstrap before Task 19" + ); + } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 140a820b6..5299a936d 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -10,6 +10,9 @@ //! streaming processor treats unknown encodings as identity, so publisher code //! must gate them out before the body enters the rewrite pipeline. //! +//! `BootManifestV1` serialization remains a pure helper in Task 8. This +//! production pipeline does not emit it until the coordinated Task 19 switch. +//! //! **Note on platform coupling:** The handler boundaries use portable HTTP //! types: [`handle_publisher_request`] and [`stream_publisher_body`] take and //! return `http::Request`/`http::Response` over `EdgeBody`, and platform I/O is diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 133e6d011..759f7216c 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -1,4 +1,52 @@ -use trusted_server_js::{all_module_ids, concatenated_hash, single_module_hash}; +use std::collections::HashSet; + +use error_stack::Report; +use trusted_server_js::{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. +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(",") + )) +} + +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. @@ -89,6 +137,53 @@ 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 tsjs_script_src_formats_unified_bundle_url_with_hash() { let src = tsjs_script_src(&["creative"]); diff --git a/crates/trusted-server-js/Cargo.toml b/crates/trusted-server-js/Cargo.toml index f3af9bfcf..fb1fdd428 100644 --- a/crates/trusted-server-js/Cargo.toml +++ b/crates/trusted-server-js/Cargo.toml @@ -19,6 +19,7 @@ test = false [build-dependencies] build-print = { 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..cba782ad4 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -12,6 +12,10 @@ use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; use build_print::{info, warn}; +use sha2::{Digest as _, Sha256}; + +const RELEASE_SENTINEL: &str = "__TSJS_RELEASE_ID_SENTINEL_V1__"; +const RELEASE_PREFIX: &[u8] = b"tsjs-release-v1\0"; fn main() { // Rebuild if TS sources change (belt-and-suspenders): enumerate every file under lib/ @@ -114,6 +118,15 @@ fn main() { dist_dir.display() ); + let release_id = validate_release(&modules, &dist_dir); + copy_bundle( + "gpt-bootstrap-fallback.js", + true, + &crate_dir, + &dist_dir, + &out_dir, + ); + info!( "tsjs: Discovered {} module files: {:?}", modules.len(), @@ -131,6 +144,14 @@ fn main() { // 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"); + writeln!( + codegen, + "pub(crate) const TSJS_RELEASE_ID: &str = \"{release_id}\";" + ) + .expect("should write generated release id"); + codegen.push_str( + "pub(crate) const GPT_BOOTSTRAP_FALLBACK: &str = include_str!(concat!(env!(\"OUT_DIR\"), \"/gpt-bootstrap-fallback.js\"));\n\n", + ); writeln!( codegen, @@ -160,6 +181,78 @@ fn main() { }); } +fn validate_release(modules: &[(String, String)], dist_dir: &Path) -> String { + let bundles = modules + .iter() + .map(|(id, filename)| format!(r#"{{"id":"{id}","file":"{filename}"}}"#)) + .collect::>() + .join(","); + let manifest = fs::read_to_string(dist_dir.join("tsjs-release-v1.json")) + .expect("should read generated release manifest"); + let manifest_prefix = r#"{"version":1,"releaseId":""#; + let manifest_value = manifest + .strip_prefix(manifest_prefix) + .expect("should use exact release manifest prefix"); + let release_id = manifest_value + .get(..64) + .expect("should contain a 64-character release id") + .to_owned(); + assert!( + release_id + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()), + "tsjs: generated manifest has invalid release id" + ); + let expected_manifest = format!( + r#"{{"version":1,"releaseId":"{release_id}","bundles":[{bundles}]}} +"# + ); + assert_eq!( + manifest, expected_manifest, + "tsjs: generated release manifest disagrees" + ); + + let mut canonical = Vec::new(); + canonical.extend_from_slice(RELEASE_PREFIX); + for (id, filename) in modules { + let source = fs::read_to_string(dist_dir.join(filename)) + .unwrap_or_else(|error| panic!("tsjs: failed to read {filename}: {error}")); + assert_eq!( + source.matches(&release_id).count(), + 1, + "tsjs: bundle {filename} must contain its release id exactly once" + ); + let normalized = source.replacen(&release_id, RELEASE_SENTINEL, 1); + canonical.extend_from_slice(id.as_bytes()); + canonical.push(0); + canonical.extend_from_slice(normalized.len().to_string().as_bytes()); + canonical.push(0); + canonical.extend_from_slice(normalized.as_bytes()); + canonical.push(0); + } + let computed = Sha256::digest(&canonical) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + assert_eq!( + computed, release_id, + "tsjs: sentinel-normalized release hash mismatch" + ); + + let fallback = fs::read_to_string(dist_dir.join("gpt-bootstrap-fallback.js")) + .expect("should read generated fallback artifact"); + assert!( + !fallback.contains(RELEASE_SENTINEL), + "tsjs: fallback sentinel remains" + ); + assert_eq!( + fallback.matches(&release_id).count(), + 1, + "tsjs: fallback artifact must carry the release id exactly once" + ); + release_id +} + fn copy_bundle(filename: &str, required: bool, crate_dir: &Path, dist_dir: &Path, out_dir: &Path) { let primary = dist_dir.join(filename); let fallback = crate_dir.join("dist").join(filename); diff --git a/crates/trusted-server-js/lib/.prettierignore b/crates/trusted-server-js/lib/.prettierignore index 9f9fd6d92..6b02254be 100644 --- a/crates/trusted-server-js/lib/.prettierignore +++ b/crates/trusted-server-js/lib/.prettierignore @@ -1,5 +1,5 @@ node_modules dist coverage -src/integrations/aps/generated/renderer_validator_v1.ts +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 8f7a3e897..bf40cf41c 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -21,11 +21,15 @@ import { brotliCompressSync, constants as zlibConstants, gzipSync } from 'node:z import { fileURLToPath } from 'node:url'; import { build } from 'vite'; +import { computeReleaseId, RELEASE_SENTINEL, stampRelease } from './scripts/release-v1.mjs'; + 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'); const metricsFile = 'tsjs-build-metrics-v1.json'; +const releaseFile = 'tsjs-release-v1.json'; +const fallbackFile = 'gpt-bootstrap-fallback.js'; const REFERENCE_INTEGRATIONS = ['creative', 'gpt', 'prebid']; @@ -77,13 +81,15 @@ const integrationModules = fs.existsSync(integrationsDir) 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`; +async function buildModule(name, entryPath, outFile = `tsjs-${name}.js`) { console.log(`[build-all] Building ${outFile} from ${path.relative(__dirname, entryPath)}`); await build({ configFile: false, root: __dirname, + define: { + __TSJS_EMBEDDED_RELEASE_ID_V1__: JSON.stringify(RELEASE_SENTINEL), + }, build: { emptyOutDir: false, outDir: distDir, @@ -114,12 +120,58 @@ await buildModule('core', path.join(srcDir, 'core', 'index.ts')); await Promise.all( integrationModules.map((name) => buildModule(name, path.join(integrationsDir, name, 'index.ts'))) ); +await buildModule( + 'gpt_bootstrap_fallback', + path.join(integrationsDir, 'gpt', 'bootstrap_fallback.ts'), + fallbackFile +); // List all built files const builtFiles = fs .readdirSync(distDir) .filter((f) => f.startsWith('tsjs-') && f.endsWith('.js')) - .sort(); + .sort((left, right) => { + if (left === 'tsjs-core.js') return -1; + if (right === 'tsjs-core.js') return 1; + return left < right ? -1 : left > right ? 1 : 0; + }); + +for (const file of builtFiles) { + const filePath = path.join(distDir, 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: ${file}`); + } + if (sentinelCount === 0) { + fs.writeFileSync(filePath, `${source}\n;void"${RELEASE_SENTINEL}";\n`); + } +} + +const releaseId = computeReleaseId( + builtFiles.map((file) => ({ + id: file.slice('tsjs-'.length, -'.js'.length), + bytes: fs.readFileSync(path.join(distDir, file)), + })) +); +for (const file of builtFiles) { + const filePath = path.join(distDir, file); + const source = fs.readFileSync(filePath, 'utf8'); + fs.writeFileSync(filePath, stampRelease(source, releaseId)); +} +const fallbackPath = path.join(distDir, fallbackFile); +const fallbackSource = fs.readFileSync(fallbackPath, 'utf8'); +fs.writeFileSync(fallbackPath, stampRelease(fallbackSource, releaseId)); + +const releaseManifest = { + version: 1, + releaseId, + bundles: builtFiles.map((file) => ({ + id: file.slice('tsjs-'.length, -'.js'.length), + file, + })), +}; +fs.writeFileSync(path.join(distDir, releaseFile), `${JSON.stringify(releaseManifest)}\n`); const referenceFiles = ['tsjs-core.js', ...REFERENCE_INTEGRATIONS.map((name) => `tsjs-${name}.js`)]; for (const file of referenceFiles) { @@ -157,3 +209,5 @@ fs.writeFileSync(path.join(distDir, metricsFile), `${JSON.stringify(metrics, nul console.log('[build-all] Built files:', builtFiles); console.log(`[build-all] Total: ${builtFiles.length} modules`); console.log(`[build-all] Wrote deterministic metrics: ${metricsFile}`); +console.log(`[build-all] Wrote release manifest: ${releaseFile}`); +console.log(`[build-all] Wrote proposed fallback artifact: ${fallbackFile}`); 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 index 2017d2abb..3cf962a9e 100644 --- a/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js +++ b/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js @@ -8,7 +8,6 @@ export const LEGACY_ADTECH_GLOBAL_ALLOWLIST = Object.freeze([ ]); export const LEGACY_RESTRICTED_IMPORT_ALLOWLIST = Object.freeze([ - 'src/core/auction.ts', 'src/core/request.ts', 'src/integrations/gpt/index.ts', 'src/integrations/prebid/index.ts', diff --git a/crates/trusted-server-js/lib/eslint.config.js b/crates/trusted-server-js/lib/eslint.config.js index b1403b1cc..bb9e9b1f8 100644 --- a/crates/trusted-server-js/lib/eslint.config.js +++ b/crates/trusted-server-js/lib/eslint.config.js @@ -160,7 +160,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.json b/crates/trusted-server-js/lib/package.json index 152ad30c6..1e42d7b78 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -6,18 +6,21 @@ "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", "dev": "vite build --watch", "test": "vitest run", + "posttest": "npm run build && npm run test:release", "test:watch": "vitest", "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" 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..be376ece7 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/print-release-id.mjs @@ -0,0 +1,75 @@ +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 manifestPath = path.join(distDirectory, 'tsjs-release-v1.json'); +const value = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + +if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.keys(value).join(',') !== 'version,releaseId,bundles' || + value.version !== 1 || + !/^[0-9a-f]{64}$/.test(value.releaseId) || + !Array.isArray(value.bundles) || + value.bundles.length === 0 +) { + throw new Error('Invalid tsjs-release-v1.json'); +} + +let previous = ''; +const normalizedBundles = []; +for (let index = 0; index < value.bundles.length; index += 1) { + const bundle = value.bundles[index]; + if ( + typeof bundle !== 'object' || + bundle === null || + Array.isArray(bundle) || + Object.keys(bundle).join(',') !== 'id,file' || + !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(bundle.id) || + bundle.file !== `tsjs-${bundle.id}.js` || + (index === 0 ? bundle.id !== 'core' : bundle.id <= previous) + ) { + throw new Error('Invalid canonical bundle inventory'); + } + const source = fs.readFileSync(path.join(distDirectory, bundle.file), 'utf8'); + if ( + source.includes('__TSJS_RELEASE_ID_SENTINEL_V1__') || + source.split(value.releaseId).length - 1 !== 1 + ) { + throw new Error(`Bundle release id mismatch: ${bundle.file}`); + } + normalizedBundles.push({ + id: bundle.id, + bytes: Buffer.from(source.replace(value.releaseId, RELEASE_SENTINEL)), + }); + previous = bundle.id; +} +const discovered = fs + .readdirSync(distDirectory) + .filter((file) => file.startsWith('tsjs-') && file.endsWith('.js')) + .sort((left, right) => { + if (left === 'tsjs-core.js') return -1; + if (right === 'tsjs-core.js') return 1; + return left < right ? -1 : left > right ? 1 : 0; + }); +if ( + discovered.join(',') !== value.bundles.map(({ file }) => file).join(',') || + computeReleaseId(normalizedBundles) !== value.releaseId +) { + throw new Error('Release manifest does not match canonical bundle bytes'); +} +const fallback = fs.readFileSync(path.join(distDirectory, 'gpt-bootstrap-fallback.js'), 'utf8'); +if ( + fallback.includes('__TSJS_RELEASE_ID_SENTINEL_V1__') || + fallback.split(value.releaseId).length - 1 !== 1 +) { + throw new Error('Generated fallback release id mismatch'); +} + +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..baa11818f --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/release-v1.mjs @@ -0,0 +1,46 @@ +import { createHash } from 'node:crypto'; + +export const RELEASE_SENTINEL = '__TSJS_RELEASE_ID_SENTINEL_V1__'; + +export function computeReleaseId(bundles) { + const hasher = createHash('sha256'); + hasher.update('tsjs-release-v1\0'); + const seen = new Set(); + for (const bundle of bundles) { + if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(bundle.id) || seen.has(bundle.id)) { + throw new Error('Invalid release bundle id'); + } + seen.add(bundle.id); + const bytes = Buffer.isBuffer(bundle.bytes) ? bundle.bytes : Buffer.from(bundle.bytes); + if (bytes.toString('utf8').split(RELEASE_SENTINEL).length - 1 !== 1) { + throw new Error(`Expected exactly one release sentinel: ${bundle.id}`); + } + hasher.update(`${bundle.id}\0${bytes.byteLength}\0`); + hasher.update(bytes); + hasher.update('\0'); + } + 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/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index d83cf6ed4..6d5ae6b62 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -16,6 +16,8 @@ import { type PrebidAdapter, type PrebidGlobalTarget, } from '../adapters/prebid'; +import type { CoreActivationContext } from '../kernel/integration_registry'; +import { createRuntime, type Runtime, type RuntimeOptions } from '../kernel/runtime'; export interface BrowserAdapters { readonly googletag: GoogletagAdapter; @@ -34,6 +36,25 @@ export interface BrowserCompositionOptions { readonly target?: BrowserAdapterTarget; } +export interface BrowserRuntimeComposition extends BrowserComposition { + readonly runtime: Runtime; +} + +export interface BrowserCoreActivations { + readonly bridgeRecognizer: ( + context: CoreActivationContext, + adapters: Readonly + ) => void; + readonly correctnessGptListeners: ( + context: CoreActivationContext, + adapters: Readonly + ) => void; +} + +export interface TestBrowserRuntimeCompositionOptions extends BrowserCompositionOptions { + readonly coreActivations: BrowserCoreActivations; +} + /** * Construct concrete browser dependencies in one place. * @@ -72,3 +93,25 @@ export function createNoopBrowserComposition(): BrowserComposition { }), }); } + +/** + * Construct the single runtime only for coordinated-cutover tests. + * + * The shipped core remains on its existing bootstrap until Task 19; keeping this + * explicit prevents an import of the composition module from claiming globals. + */ +export function createTestBrowserRuntimeComposition( + runtimeOptions: RuntimeOptions, + compositionOptions: TestBrowserRuntimeCompositionOptions +): BrowserRuntimeComposition { + const composition = createBrowserComposition(compositionOptions); + const runtime = createRuntime({ + ...runtimeOptions, + activateCore: (context) => { + compositionOptions.coreActivations.bridgeRecognizer(context, composition.adapters); + compositionOptions.coreActivations.correctnessGptListeners(context, composition.adapters); + runtimeOptions.activateCore?.(context); + }, + }); + return Object.freeze({ adapters: composition.adapters, runtime }); +} diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index f8ad13351..bfe0ce078 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -2,23 +2,36 @@ // and parses OpenRTB seatbid responses. Used by both the core requestAds flow // and the Prebid.js trustedServer adapter. -import { parseApsRendererDescriptor, validateApsRenderer } 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 { - AdmRenderSourceV1, ApsRendererV1, AuctionDecisionSetV1, - AuctionSlotFailureReason, BidRenderSourceV1, - BrowserAuctionBidV1, - BrowserAuctionProjectionV1, - CacheFetchPolicyV1, - CacheRenderSourceV1, SlotAuctionDecisionV1, } from './types'; +export { + MAX_BROWSER_AUCTION_PROJECTION_BYTES, + isRendererReservationIdV1, + parseBrowserAuctionProjectionV1, +} from './contracts/auction_projection'; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -97,417 +110,7 @@ export interface TrustedServerAuctionResponseV1 { bids: TrustedServerAuctionBidV1[]; } -export const MAX_BROWSER_AUCTION_PROJECTION_BYTES = 8 * 1024 * 1024; - -const MAX_AUCTION_RESULTS = 256; -const MAX_TARGETING_ENTRIES = 32; -const MAX_ADM_BYTES = 512 * 1024; -const MAX_URL_BYTES = 4096; -const textEncoder = new TextEncoder(); -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', -]); - -/** Whether a value is one exact server-minted renderer reservation identity. */ -export function isRendererReservationIdV1(value: unknown): value is string { - return typeof value === 'string' && reservationIdPattern.test(value); -} - -function ownDataObject( - 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 ( - expectedKeys && - (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; -} - -function ownDataArray(value: unknown, maximum: number): unknown[] | undefined { - if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; - if (value.length > maximum || Object.getOwnPropertySymbols(value).length !== 0) return undefined; - const names = Object.getOwnPropertyNames(value); - if (names.length !== value.length + 1 || !names.includes('length')) return undefined; - 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; - } - return value; -} - -function validUnicodeScalars(value: string): boolean { - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); - if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(index + 1); - if (!(next >= 0xdc00 && next <= 0xdfff)) return false; - index += 1; - } else if (code >= 0xdc00 && code <= 0xdfff) { - return false; - } - } - return true; -} - -function 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; -} - -function validBoundedString( - value: unknown, - maximumBytes: number, - options: { allowControls?: boolean; maximumScalars?: number } = {} -): value is string { - return ( - typeof value === 'string' && - value.length > 0 && - validUnicodeScalars(value) && - (options.allowControls === true || !hasAsciiControl(value)) && - textEncoder.encode(value).length <= maximumBytes && - (options.maximumScalars === undefined || Array.from(value).length <= options.maximumScalars) - ); -} - -function validDimension(value: unknown): value is number { - return ( - typeof value === 'number' && - Number.isFinite(value) && - Number.isInteger(value) && - value >= 1 && - value <= 4096 - ); -} - -function parseRenderSource( - value: unknown, - cachePolicy?: Readonly -): BidRenderSourceV1 | undefined { - const record = ownDataObject(value); - if (!record || typeof record.type !== 'string') return undefined; - - if (record.type === 'aps') { - const keys = [ - 'type', - 'version', - 'accountId', - 'bidId', - ...(Object.prototype.hasOwnProperty.call(record, 'creativeId') ? ['creativeId'] : []), - 'tagType', - 'creativeUrl', - 'aaxResponse', - 'width', - 'height', - ]; - if (!ownDataObject(value, keys)) return undefined; - const renderer = validateApsRenderer(value); - 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' || - !cacheIdPattern.test(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; -} - -function parseDecisionSet(value: unknown): AuctionDecisionSetV1 | undefined { - const record = ownDataObject(value, ['version', 'auctionId', 'results']); - if (!record || record.version !== 1 || typeof record.auctionId !== 'string') return undefined; - if (!auctionIdPattern.test(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 (const raw of results) { - 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 || - typeof winner.candidateId !== 'string' || - !candidateIdPattern.test(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 = {}; - for (const [key, entry] of entries.sort(([left], [right]) => - left < right ? -1 : left > right ? 1 : 0 - )) { - if ( - key === 'hb_adid' || - !targetingKeyPattern.test(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 || - typeof bid.candidateId !== 'string' || - !candidateIdPattern.test(bid.candidateId) || - !validBoundedString(bid.slot, 256) || - typeof bid.provider !== 'string' || - !providerPattern.test(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 = parseRenderSource(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, - }; -} - -/** Validate, canonicalize, and deep-copy a complete browser auction projection. */ -export function parseBrowserAuctionProjectionV1( - value: unknown, - cachePolicyValue?: unknown -): BrowserAuctionProjectionV1 | undefined { - const cachePolicy = - cachePolicyValue === undefined ? undefined : parseCacheFetchPolicyV1(cachePolicyValue); - if (cachePolicyValue !== undefined && !cachePolicy) return undefined; - const record = ownDataObject(value, ['version', 'auction', 'bids']); - if (!record || record.version !== 1) return undefined; - const auction = parseDecisionSet(record.auction); - const rawBids = ownDataArray(record.bids, MAX_AUCTION_RESULTS); - if (!auction || !rawBids) return undefined; - const bids: BrowserAuctionBidV1[] = []; - const candidateIds = new Set(); - const reservationIds = new Set(); - for (const raw of rawBids) { - 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); - } - - const winners = auction.results.filter( - (result): result is Extract => - result.outcome === 'winner' - ); - if ( - winners.length !== bids.length || - winners.some( - (winner, index) => - bids[index]?.candidateId !== winner.candidateId || bids[index]?.slot !== winner.slot - ) - ) { - return undefined; - } - - const projection: BrowserAuctionProjectionV1 = { version: 1, auction, bids }; - if ( - textEncoder.encode(JSON.stringify(projection)).length > MAX_BROWSER_AUCTION_PROJECTION_BYTES - ) { - return undefined; - } - return projection; -} +/* 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( @@ -528,8 +131,7 @@ export function parseTrustedServerAuctionResponseV1( const bids: TrustedServerAuctionBidV1[] = []; for (const rawSeat of seatbids) { const seat = ownDataObject(rawSeat, ['seat', 'bid']); - if (!seat || typeof seat.seat !== 'string' || !providerPattern.test(seat.seat)) - return undefined; + 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) { @@ -555,8 +157,7 @@ export function parseTrustedServerAuctionResponseV1( !trusted || !isRendererReservationIdV1(bid.id) || !validBoundedString(bid.impid, 256) || - typeof trusted.candidate_id !== 'string' || - !candidateIdPattern.test(trusted.candidate_id) || + !isAuctionCandidateIdV1(trusted.candidate_id) || trusted.slot_id !== bid.impid || typeof bid.price !== 'number' || !Number.isFinite(bid.price) || @@ -610,7 +211,7 @@ export function parseTrustedServerAuctionResponseV1( return !winner || winner.slot !== bid.impid; }) || winners.some((winner) => !bids.some((bid) => bid.candidateId === winner.candidateId)) || - textEncoder.encode(JSON.stringify(value)).length > MAX_BROWSER_AUCTION_PROJECTION_BYTES + jsonUtf8ByteLength(value) > MAX_BROWSER_AUCTION_PROJECTION_BYTES ) { return undefined; } 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..b31d6e789 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts @@ -0,0 +1,452 @@ +import { parseCacheFetchPolicyV1 } from '../config'; +import type { + AdmRenderSourceV1, + AuctionDecisionSetV1, + AuctionSlotFailureReason, + BidRenderSourceV1, + BrowserAuctionBidV1, + BrowserAuctionProjectionV1, + 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_ADM_BYTES = 512 * 1024; +const MAX_URL_BYTES = 4096; +const textEncoder = new TextEncoder(); +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', +]); + +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 = Object.getOwnPropertyNames(value); + if ( + expectedKeys && + (names.length !== expectedKeys.length || expectedKeys.some((key) => !names.includes(key))) + ) { + return undefined; + } + const snapshot: Record = Object.create(null) as Record; + for (const name of names) { + 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 = Object.getOwnPropertyNames(value); + if (names.length !== value.length + 1 || !names.includes('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 validUnicodeScalars(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + } + return true; +} + +function 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 { + return ( + typeof value === 'string' && + value.length > 0 && + validUnicodeScalars(value) && + (options.allowControls === true || !hasAsciiControl(value)) && + textEncoder.encode(value).length <= maximumBytes && + (options.maximumScalars === undefined || Array.from(value).length <= options.maximumScalars) + ); +} + +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' && candidateIdPattern.test(value); +} + +export function isAuctionProviderIdV1(value: unknown): value is string { + return typeof value === 'string' && providerPattern.test(value); +} + +export function jsonUtf8ByteLength(value: unknown): number { + return textEncoder.encode(JSON.stringify(value)).length; +} + +/** Whether a value is one exact server-minted renderer reservation identity. */ +export function isRendererReservationIdV1(value: unknown): value is string { + return typeof value === 'string' && reservationIdPattern.test(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 = [ + 'type', + 'version', + 'accountId', + 'bidId', + ...(Object.prototype.hasOwnProperty.call(record, 'creativeId') ? ['creativeId'] : []), + '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' || + !cacheIdPattern.test(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 (!auctionIdPattern.test(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 (const raw of results) { + 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 = {}; + for (const [key, entry] of entries.sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0 + )) { + if ( + key === 'hb_adid' || + !targetingKeyPattern.test(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, + }; +} + +/** 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', 'bids']); + if (!record || record.version !== 1) return undefined; + const auction = parseAuctionDecisionSetV1(record.auction); + const rawBids = ownDataArray(record.bids, MAX_AUCTION_RESULTS); + if (!auction || !rawBids) return undefined; + const bids: BrowserAuctionBidV1[] = []; + const candidateIds = new Set(); + const reservationIds = new Set(); + for (const raw of rawBids) { + 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); + } + + const winners = auction.results.filter( + (result): result is Extract => + result.outcome === 'winner' + ); + if ( + winners.length !== bids.length || + winners.some( + (winner, index) => + bids[index]?.candidateId !== winner.candidateId || bids[index]?.slot !== winner.slot + ) + ) { + return undefined; + } + + const projection: BrowserAuctionProjectionV1 = { version: 1, auction, 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/integrations/aps/generated/renderer_validator_v1.ts b/crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_v1.ts similarity index 100% rename from crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts rename to crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_v1.ts 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..9b21ab312 100644 --- a/crates/trusted-server-js/lib/src/core/global.d.ts +++ b/crates/trusted-server-js/lib/src/core/global.d.ts @@ -2,6 +2,7 @@ import type { TsjsApi } from './types'; declare global { interface Window { + /** Publisher-owned object identity is retained through dormant Task 8 bootstrap tests. */ tsjs?: TsjsApi; pbjs?: TsjsApi; } diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index 5d8e41971..2806354b3 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -6,6 +6,8 @@ export type { GptDiagnosticsRequestCycle, TsjsApi, } from './types'; +// Erased coordinated-cutover types only. Production ownership remains below until Task 19. +export type { Runtime, RuntimeOptions, RuntimeState } from '../kernel/runtime'; import type { TsjsApi } from './types'; import { addAdUnits } from './registry'; import { renderAdUnit, renderAllAdUnits } from './render'; diff --git a/crates/trusted-server-js/lib/src/core/queue.ts b/crates/trusted-server-js/lib/src/core/queue.ts index 73c2741be..335d63c78 100644 --- a/crates/trusted-server-js/lib/src/core/queue.ts +++ b/crates/trusted-server-js/lib/src/core/queue.ts @@ -1,6 +1,229 @@ -// 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); + } + 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/release.ts b/crates/trusted-server-js/lib/src/core/release.ts new file mode 100644 index 000000000..125b2ccde --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/release.ts @@ -0,0 +1,4 @@ +declare const __TSJS_EMBEDDED_RELEASE_ID_V1__: string; + +/** Build-stamped identity of the exact canonical production bundle set. */ +export const EMBEDDED_RELEASE_ID = __TSJS_EMBEDDED_RELEASE_ID_V1__; diff --git a/crates/trusted-server-js/lib/src/core/surface.ts b/crates/trusted-server-js/lib/src/core/surface.ts new file mode 100644 index 000000000..4e504c06e --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/surface.ts @@ -0,0 +1,30 @@ +/** Explicit hard-cutover inventory removed before either terminal API is exposed. */ +export const LEGACY_TSJS_FIELDS = Object.freeze([ + 'adSlots', + 'bids', + 'apsPrebidRenderers', + 'adInit', + 'renderAdUnit', + 'renderAllAdUnits', + 'setConfig', + 'getConfig', + 'renders', + 'renderLog', + 'renderGeneration', + 'renderSeq', + 'prevGptSlots', + 'servicesEnabled', + 'divToSlotId', + 'firedBeacons', + 'prevSlotTargetingKeys', + 'adInitRefreshInProgress', + 'gptInitialLoadDisabled', + 'gptSlotHandoffs', + 'gptSlotHandoffInternal', + 'spaHookInstalled', + 'navGeneration', + 'scheduleInitialAdInit', + 'gptDiagnostics', +]); + +export const FALLBACK_REMOVED_FIELDS = Object.freeze([...LEGACY_TSJS_FIELDS, 'diagnostics']); 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 caf198f8c..e17db2575 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -1,10 +1,8 @@ import { log } from '../../core/log'; -import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types'; +import type { ApsPrebidRendererEntry, TsjsApi } from '../../core/types'; +import { validateApsRenderer } from '../../core/contracts/aps_renderer'; -import { - classifyApsRendererDescriptorV1, - classifyApsRendererV1, -} from './generated/renderer_validator_v1'; +export { parseApsRendererDescriptor, validateApsRenderer } from '../../core/contracts/aps_renderer'; export const APS_RENDERER_PATH = '/integrations/aps/renderer'; export const APS_RENDERER_SANDBOX = @@ -21,12 +19,6 @@ 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); } @@ -37,34 +29,6 @@ function isExactRendererResult(value: unknown): value is Record return actual.length === 2 && actual[0] === 'message' && actual[1] === 'nonce'; } -/** Parse only the versioned descriptor shape; decoded-envelope trust checks happen separately. */ -export function parseApsRendererDescriptor(value: unknown): ApsRendererV1 | undefined { - if (classifyApsRendererDescriptorV1(value) !== 'accepted') { - return undefined; - } - - return value as unknown as ApsRendererV1; -} - -/** 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; - } - - 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; -} - function validPrebidIdentity(value: unknown): value is string { return ( typeof value === 'string' && diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/bootstrap_fallback.ts b/crates/trusted-server-js/lib/src/integrations/gpt/bootstrap_fallback.ts new file mode 100644 index 000000000..951d62070 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt/bootstrap_fallback.ts @@ -0,0 +1,52 @@ +import { canPublishTerminalFields, prepareQueue, publishQueue } from '../../core/queue'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; +import { FALLBACK_REMOVED_FIELDS } from '../../core/surface'; +import { createFallbackFields } from '../../kernel/fallback'; + +function installGeneratedBootstrapFallback( + target: object & { que?: unknown; boot?: unknown } +): void { + const bootDescriptor = Object.getOwnPropertyDescriptor(target, 'boot'); + const boot = bootDescriptor && 'value' in bootDescriptor ? bootDescriptor.value : {}; + const fields = createFallbackFields({ + releaseId: EMBEDDED_RELEASE_ID, + reason: 'bundle_partial', + boot, + }); + if (!canPublishTerminalFields(target, fields, FALLBACK_REMOVED_FIELDS)) return; + const ingress = prepareQueue(target); + const published = publishQueue(target, ingress, fields, FALLBACK_REMOVED_FIELDS); + published.drain(); +} + +const browser = (globalThis as unknown as { window?: { tsjs?: unknown } }).window; +if (browser) { + try { + const namespaceDescriptor = Object.getOwnPropertyDescriptor(browser, 'tsjs'); + const existing = + namespaceDescriptor && 'value' in namespaceDescriptor ? namespaceDescriptor.value : undefined; + let target: object & { que?: unknown; boot?: unknown }; + if (typeof existing === 'object' && existing !== null && !Array.isArray(existing)) { + target = existing as object & { que?: unknown; boot?: unknown }; + } else { + target = {}; + Object.defineProperty(browser, 'tsjs', { + configurable: true, + enumerable: true, + value: target, + writable: true, + }); + } + if (!Object.getOwnPropertyDescriptor(target, 'boot')) { + Object.defineProperty(target, 'boot', { + configurable: true, + enumerable: true, + value: {}, + writable: true, + }); + } + installGeneratedBootstrapFallback(target); + } catch { + // A namespace that cannot be defined cannot expose any fallback API safely. + } +} diff --git a/crates/trusted-server-js/lib/src/kernel/fallback.ts b/crates/trusted-server-js/lib/src/kernel/fallback.ts new file mode 100644 index 000000000..b61d44b39 --- /dev/null +++ b/crates/trusted-server-js/lib/src/kernel/fallback.ts @@ -0,0 +1,653 @@ +import { parseCacheFetchPolicyV1 } from '../core/config'; +import { + parseBrowserAuctionProjectionV1, + validBoundedString, +} from '../core/contracts/auction_projection'; +import { log } from '../core/log'; +import type { BootManifestV1 } from '../core/types'; + +import type { BootFailureReason } from './integration_registry'; + +const textEncoder = new TextEncoder(); +const MAX_AUCTION_BODY_BYTES = 256 * 1024; +const MAX_JSON_ARRAY_ITEMS = Math.floor((MAX_AUCTION_BODY_BYTES - 1) / 2); +const SAFE_PROJECTION = { + version: 1, + auction: { version: 1, auctionId: 'fallback', results: [] }, + bids: [], +} as const; + +export class RequestAdsInputError extends Error { + public readonly code: + | 'invalid_options' + | 'invalid_slots' + | 'empty_slots' + | 'duplicate_slot' + | 'invalid_timeout' + | 'invalid_signal'; + + public constructor(code: RequestAdsInputError['code']) { + super(code); + this.name = 'RequestAdsInputError'; + this.code = code; + } +} + +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; + } +} + +export class TsjsUnavailableError extends Error { + public readonly code = 'runtime_unavailable' as const; + public readonly releaseId: string; + public readonly reason: BootFailureReason; + + public constructor(releaseId: string, reason: BootFailureReason) { + super('TSJS runtime is unavailable'); + this.name = 'TsjsUnavailableError'; + this.releaseId = releaseId; + this.reason = reason; + } +} + +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; + const output: Record = Object.create(null) as Record; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') 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 exactKeys(record: Record, keys: readonly string[]): boolean { + const actual = Object.keys(record); + return actual.length === keys.length && actual.every((key) => keys.includes(key)); +} + +function snapshotOwnArray(value: unknown, maximum: number): readonly unknown[] | undefined { + try { + if (!Array.isArray(value) || value.length > maximum) { + return undefined; + } + if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const names = Object.getOwnPropertyNames(value); + if (names.length !== value.length + 1 || !names.includes('length')) return undefined; + const copy: 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; + copy.push(descriptor.value); + } + return copy; + } catch { + return undefined; + } +} + +function manifestMatches(candidate: unknown, expected: BootManifestV1): boolean { + const record = ownDataRecord(candidate); + if (!record || !exactKeys(record, ['version', 'releaseId', 'integrations'])) return false; + if (record.version !== 1 || record.releaseId !== expected.releaseId) return false; + const integrations = snapshotOwnArray(record.integrations, 16); + if (!integrations || integrations.length !== expected.integrations.length) return false; + return integrations.every((entry, index) => { + const fields = ownDataRecord(entry); + const accepted = expected.integrations[index]; + return Boolean( + accepted && + fields && + exactKeys(fields, ['id', 'required']) && + fields.id === accepted.id && + fields.required === true + ); + }); +} + +function deepFreeze(value: T): Readonly { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return value; + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor && 'value' in descriptor) deepFreeze(descriptor.value); + } + return Object.freeze(value); +} + +function readBootField(boot: unknown, key: string): unknown { + const record = ownDataRecord(boot); + return record?.[key]; +} + +function parseCachePolicy(candidate: unknown): ReturnType { + try { + return parseCacheFetchPolicyV1(candidate); + } catch { + return undefined; + } +} + +/** Validate and freeze the complete boot snapshot used by a committed kernel. */ +export function buildKernelBoot( + releaseId: string, + manifest: BootManifestV1, + candidate: unknown +): Readonly | undefined { + const record = ownDataRecord(candidate); + if (!record) return undefined; + const transportKeys = ['auctionProjection', 'creative', 'diagnostics']; + if (Object.prototype.hasOwnProperty.call(record, 'cachePolicy')) + transportKeys.push('cachePolicy'); + const completeKeys = ['abi', 'releaseId', 'manifest', ...transportKeys]; + if (!exactKeys(record, transportKeys) && !exactKeys(record, completeKeys)) return undefined; + if ( + completeKeys.length === Object.keys(record).length && + (record.abi !== 1 || + record.releaseId !== releaseId || + !manifestMatches(record.manifest, manifest)) + ) { + return undefined; + } + const cachePolicy = + record.cachePolicy === undefined ? undefined : parseCachePolicy(record.cachePolicy); + if (record.cachePolicy !== undefined && !cachePolicy) return undefined; + const auctionProjection = parseBrowserAuctionProjectionV1(record.auctionProjection, cachePolicy); + const creative = ownDataRecord(record.creative); + const diagnostics = ownDataRecord(record.diagnostics); + const gptDiagnostics = ownDataRecord(diagnostics?.gpt); + if ( + !auctionProjection || + !creative || + !exactKeys(creative, ['version', 'enabled', 'clickGuard', 'renderGuard']) || + creative.version !== 1 || + typeof creative.enabled !== 'boolean' || + typeof creative.clickGuard !== 'boolean' || + typeof creative.renderGuard !== 'boolean' || + !diagnostics || + !exactKeys(diagnostics, ['version', 'renderTraceOverlay', 'gpt']) || + diagnostics.version !== 1 || + typeof diagnostics.renderTraceOverlay !== 'boolean' || + !gptDiagnostics || + !exactKeys(gptDiagnostics, ['active']) || + typeof gptDiagnostics.active !== 'boolean' + ) { + return undefined; + } + const diagnosticsModule = manifest.integrations.filter(({ id }) => id === 'gpt_diagnostics'); + if ( + (gptDiagnostics.active && diagnosticsModule.length !== 1) || + (!gptDiagnostics.active && diagnosticsModule.length !== 0) + ) { + return undefined; + } + return deepFreeze({ + abi: 1, + releaseId, + manifest, + auctionProjection, + ...(cachePolicy ? { cachePolicy } : {}), + creative: { + version: 1, + enabled: creative.enabled, + clickGuard: creative.clickGuard, + renderGuard: creative.renderGuard, + }, + diagnostics: { + version: 1, + renderTraceOverlay: diagnostics.renderTraceOverlay, + gpt: { active: gptDiagnostics.active }, + }, + }); +} + +/** Build the immutable boot snapshot shared by every terminal fallback. */ +export function buildFallbackBoot(releaseId: string, candidate: unknown): Readonly { + const cacheCandidate = readBootField(candidate, 'cachePolicy'); + const cachePolicy = cacheCandidate === undefined ? undefined : parseCachePolicy(cacheCandidate); + const projection = + parseBrowserAuctionProjectionV1(readBootField(candidate, 'auctionProjection'), cachePolicy) ?? + SAFE_PROJECTION; + return deepFreeze({ + abi: 1, + releaseId, + manifest: { version: 1, releaseId, integrations: [] }, + auctionProjection: projection, + ...(cachePolicy ? { cachePolicy } : {}), + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }); +} + +function validSlotId(value: unknown): value is string { + return validBoundedString(value, 256); +} + +function readAborted(signal: unknown): boolean | undefined { + try { + const getter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; + return getter?.call(signal) as boolean | undefined; + } catch { + return undefined; + } +} + +function validateRequestOptions(value: unknown): { + readonly slots: readonly string[] | undefined; + readonly aborted: boolean; +} { + if (value === undefined) return { slots: undefined, aborted: false }; + const options = ownDataRecord(value); + if ( + !options || + !Object.keys(options).every((key) => ['slots', 'timeoutMs', 'signal'].includes(key)) + ) { + throw new RequestAdsInputError('invalid_options'); + } + let slots: readonly string[] | undefined; + if (Object.prototype.hasOwnProperty.call(options, 'slots')) { + const candidateSlots = snapshotOwnArray(options.slots, 256); + if (!candidateSlots) { + throw new RequestAdsInputError('invalid_slots'); + } + if (candidateSlots.length === 0) throw new RequestAdsInputError('empty_slots'); + const seen = new Set(); + const copy: string[] = []; + for (const slot of candidateSlots) { + if (!validSlotId(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); + } + if ( + Object.prototype.hasOwnProperty.call(options, 'timeoutMs') && + (!Number.isInteger(options.timeoutMs) || + (options.timeoutMs as number) < 100 || + (options.timeoutMs as number) > 30_000) + ) { + throw new RequestAdsInputError('invalid_timeout'); + } + let aborted = false; + if (Object.prototype.hasOwnProperty.call(options, 'signal')) { + const candidate = readAborted(options.signal); + if (candidate === undefined) throw new RequestAdsInputError('invalid_signal'); + aborted = candidate; + } + return { slots, aborted }; +} + +interface JsonMeasurement { + readonly bytes: number; +} + +interface JsonMeasurementContext { + readonly memo: WeakMap; + readonly snapshots: WeakMap; +} + +interface JsonNode { + readonly entries: readonly JsonEntry[]; +} + +interface JsonEntry { + readonly prefixBytes: number; + readonly value: unknown; +} + +interface JsonFrame { + readonly object: object; + readonly node: JsonNode; + bytes: number; + index: number; +} + +const JSON_TOO_LARGE = Symbol('json_too_large'); +const TOO_LARGE_MEASUREMENT = Object.freeze({ bytes: MAX_AUCTION_BODY_BYTES + 1 }); + +function boundedByteSum(left: number, right: number): number { + return Math.min(MAX_AUCTION_BODY_BYTES + 1, left + right); +} + +function primitiveJsonBytes(value: unknown): number | undefined { + if (value === null) return 4; + if (typeof value === 'boolean') return value ? 4 : 5; + if (typeof value === 'string') return textEncoder.encode(JSON.stringify(value)).length; + if (typeof value === 'number' && Number.isFinite(value)) return String(value).length; + return undefined; +} + +function snapshotJsonNode( + value: unknown, + context: JsonMeasurementContext, + recordSnapshot?: Record +): JsonNode | typeof JSON_TOO_LARGE | undefined { + if (typeof value !== 'object' || value === null) return undefined; + if (context.snapshots.has(value)) { + return context.snapshots.get(value) ?? undefined; + } + let node: JsonNode | typeof JSON_TOO_LARGE | undefined; + try { + let entries: JsonEntry[]; + if (recordSnapshot) { + entries = Object.keys(recordSnapshot).map((key, index) => ({ + prefixBytes: (index === 0 ? 0 : 1) + textEncoder.encode(JSON.stringify(key)).length + 1, + value: recordSnapshot[key], + })); + } else if (Array.isArray(value)) { + if (value.length > MAX_JSON_ARRAY_ITEMS) { + node = JSON_TOO_LARGE; + return node; + } + const values = snapshotOwnArray(value, MAX_JSON_ARRAY_ITEMS); + if (!values) return undefined; + entries = values.map((entry, index) => ({ + prefixBytes: index === 0 ? 0 : 1, + value: entry, + })); + } else { + const record = ownDataRecord(value); + if (!record) return undefined; + entries = Object.keys(record).map((key, index) => ({ + prefixBytes: (index === 0 ? 0 : 1) + textEncoder.encode(JSON.stringify(key)).length + 1, + value: record[key], + })); + } + node = Object.freeze({ entries: Object.freeze(entries) }); + return node; + } catch { + return undefined; + } finally { + context.snapshots.set(value, node ?? null); + } +} + +function measureJsonData( + value: unknown, + context: JsonMeasurementContext, + recordSnapshot?: Record +): JsonMeasurement | undefined { + const primitiveBytes = primitiveJsonBytes(value); + if (primitiveBytes !== undefined) return { bytes: primitiveBytes }; + if (typeof value !== 'object' || value === null) return undefined; + const cached = context.memo.get(value); + if (cached) return cached; + const root = snapshotJsonNode(value, context, recordSnapshot); + if (root === JSON_TOO_LARGE) return TOO_LARGE_MEASUREMENT; + if (!root) return undefined; + + const active = new Set([value]); + const stack: JsonFrame[] = [{ object: value, node: root, bytes: 2, index: 0 }]; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (!frame) return undefined; + if (frame.index >= frame.node.entries.length) { + const measurement = Object.freeze({ bytes: frame.bytes }); + context.memo.set(frame.object, measurement); + active.delete(frame.object); + stack.pop(); + const parent = stack[stack.length - 1]; + if (!parent) return measurement; + parent.bytes = boundedByteSum(parent.bytes, measurement.bytes); + if (parent.bytes > MAX_AUCTION_BODY_BYTES) return TOO_LARGE_MEASUREMENT; + continue; + } + + const entry = frame.node.entries[frame.index]; + frame.index += 1; + if (!entry) return undefined; + frame.bytes = boundedByteSum(frame.bytes, entry.prefixBytes); + if (frame.bytes > MAX_AUCTION_BODY_BYTES) return TOO_LARGE_MEASUREMENT; + const childBytes = primitiveJsonBytes(entry.value); + if (childBytes !== undefined) { + frame.bytes = boundedByteSum(frame.bytes, childBytes); + if (frame.bytes > MAX_AUCTION_BODY_BYTES) return TOO_LARGE_MEASUREMENT; + continue; + } + if (typeof entry.value !== 'object' || entry.value === null || active.has(entry.value)) { + return undefined; + } + const childMeasurement = context.memo.get(entry.value); + if (childMeasurement) { + frame.bytes = boundedByteSum(frame.bytes, childMeasurement.bytes); + if (frame.bytes > MAX_AUCTION_BODY_BYTES) return TOO_LARGE_MEASUREMENT; + continue; + } + const childNode = snapshotJsonNode(entry.value, context); + if (childNode === JSON_TOO_LARGE) return TOO_LARGE_MEASUREMENT; + if (!childNode) return undefined; + active.add(entry.value); + stack.push({ object: entry.value, node: childNode, bytes: 2, index: 0 }); + } + return undefined; +} + +function measureJsonRecord( + value: unknown, + context: JsonMeasurementContext +): JsonMeasurement | undefined { + try { + if (Array.isArray(value)) return undefined; + } catch { + return undefined; + } + const record = ownDataRecord(value); + return record ? measureJsonData(value, context, record) : undefined; +} + +function validateProgrammaticUnits(value: unknown, knownSlots: ReadonlySet): void { + let units: readonly unknown[] | undefined; + try { + units = Array.isArray(value) ? snapshotOwnArray(value, 256) : [value]; + } catch { + throw new AdUnitRegistrationError('invalid_units'); + } + if (!units) throw new AdUnitRegistrationError('invalid_units'); + if (units.length === 0 || units.length > 256) throw new AdUnitRegistrationError('invalid_units'); + const seen = new Set(); + const measurementContext: JsonMeasurementContext = { + memo: new WeakMap(), + snapshots: 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 (!validSlotId(unit.code)) throw new AdUnitRegistrationError('invalid_code', index); + if (seen.has(unit.code)) throw new AdUnitRegistrationError('duplicate_code', index); + if (knownSlots.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 sizes = snapshotOwnArray(banner.sizes, MAX_JSON_ARRAY_ITEMS); + if (!sizes || sizes.length === 0) { + throw new AdUnitRegistrationError('invalid_media_types', index); + } + for (const size of sizes) { + const dimensions = snapshotOwnArray(size, 2); + if ( + !dimensions || + dimensions.length !== 2 || + dimensions.some( + (dimension) => + typeof dimension !== 'number' || + !Number.isFinite(dimension) || + !Number.isInteger(dimension) || + dimension <= 0 + ) + ) { + throw new AdUnitRegistrationError('invalid_dimensions', index); + } + if (dimensions.some((dimension) => (dimension as number) > 4096)) { + throw new AdUnitRegistrationError('dimensions_out_of_range', index); + } + } + if (unit.bids !== undefined) { + const bids = snapshotOwnArray(unit.bids, MAX_JSON_ARRAY_ITEMS); + if (!bids) throw new AdUnitRegistrationError('invalid_bids', index); + for (const rawBid of bids) { + const bid = ownDataRecord(rawBid); + if (!bid || (!exactKeys(bid, ['bidder']) && !exactKeys(bid, ['bidder', 'params']))) { + throw new AdUnitRegistrationError('invalid_bids', index); + } + if ( + typeof bid.bidder !== 'string' || + textEncoder.encode(bid.bidder).length > 64 || + bid.bidder.length === 0 + ) { + throw new AdUnitRegistrationError('invalid_bidder', index); + } + if (bid.params !== undefined) { + const measured = measureJsonRecord(bid.params, measurementContext); + if (!measured) { + throw new AdUnitRegistrationError('invalid_params', index); + } + } + } + } + } + const measured = measureJsonData(units, measurementContext); + if (!measured) { + throw new AdUnitRegistrationError('invalid_params'); + } + if (measured.bytes > MAX_AUCTION_BODY_BYTES) { + throw new AdUnitRegistrationError('request_body_too_large'); + } + if (knownSlots.size + units.length > 256) { + throw new AdUnitRegistrationError('registry_capacity'); + } +} + +const LOG_LEVELS = Object.freeze({ + silent: true, + error: true, + warn: true, + info: true, + debug: true, +}); + +function observeLog(callback: () => void): void { + try { + callback(); + } catch { + // The public logger is observation only. + } +} + +export const publicLog = Object.freeze({ + setLevel: (level: Parameters[0]) => { + if (!Object.prototype.hasOwnProperty.call(LOG_LEVELS, level)) { + throw new TypeError('Invalid TSJS log level'); + } + log.setLevel(level); + }, + getLevel: () => log.getLevel(), + error: (...values: readonly unknown[]) => observeLog(() => log.error(...values)), + warn: (...values: readonly unknown[]) => observeLog(() => log.warn(...values)), + info: (...values: readonly unknown[]) => observeLog(() => log.info(...values)), + debug: (...values: readonly unknown[]) => observeLog(() => log.debug(...values)), +}); + +export interface FallbackFieldsOptions { + readonly releaseId: string; + readonly reason: BootFailureReason; + readonly boot: unknown; +} + +/** Construct the complete, non-rendering public shell without runtime services. */ +export function createFallbackFields( + options: FallbackFieldsOptions +): Readonly> { + const boot = buildFallbackBoot(options.releaseId, options.boot); + const projection = boot as { + readonly auctionProjection: { + readonly auction: { readonly results: readonly { slot: string }[] }; + }; + }; + const knownSlots = Object.freeze( + projection.auctionProjection.auction.results.map(({ slot }) => slot) + ); + const known = new Set(knownSlots); + const fields: Record = {}; + Object.defineProperties(fields, { + version: { enumerable: true, value: '1.0.0' }, + releaseId: { enumerable: true, value: options.releaseId }, + boot: { enumerable: true, value: boot }, + log: { enumerable: true, value: publicLog }, + _registerIntegration: { enumerable: true, value: () => false }, + addAdUnits: { + enumerable: true, + value: (units: unknown) => { + validateProgrammaticUnits(units, known); + throw new TsjsUnavailableError(options.releaseId, options.reason); + }, + }, + requestAds: { + enumerable: true, + value: async (requestOptions?: unknown) => { + const validated = validateRequestOptions(requestOptions); + const selected = validated.slots ?? knownSlots; + return deepFreeze({ + slots: selected.map((slot) => + known.has(slot) + ? validated.aborted + ? { slot, path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' } + : { slot, path: 'primary', outcome: 'failed', reason: options.reason } + : { slot, path: 'primary', outcome: 'failed', reason: 'slot_unresolved' } + ), + }); + }, + }, + _internal: { + enumerable: false, + value: Object.freeze({ + state: 'fallback', + releaseId: options.releaseId, + reason: options.reason, + }), + }, + }); + return Object.freeze(fields); +} diff --git a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts index 65aff7c45..03470a051 100644 --- a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts +++ b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts @@ -83,6 +83,8 @@ export interface IntegrationRegistryOptions { readonly now?: () => number; readonly signal?: AbortSignal; readonly getBindings?: (id: string) => IntegrationBindings; + /** Monotonic bootstrap-generation guard supplied by the composition owner. */ + readonly isCurrentOwner?: () => boolean; readonly onDisposalError?: (error: unknown) => void; readonly onRuntimeFailure?: (failure: IntegrationRuntimeFailure) => void; } @@ -255,6 +257,7 @@ class IntegrationRegistryOwner { private readonly coreScope: DisposableStack; private readonly now: () => number; private readonly getBindings: (id: string) => IntegrationBindings; + private readonly isCurrentOwner: () => boolean; private readonly startedAtMs: number; private readonly releaseId: string; private readonly ownerSignal: AbortSignal | undefined; @@ -277,6 +280,7 @@ class IntegrationRegistryOwner { config: EMPTY_BINDING, interfaces: EMPTY_BINDING, })); + this.isCurrentOwner = options.isCurrentOwner ?? (() => true); this.ownerSignal = options.signal; this.onDisposalError = options.onDisposalError ?? (() => undefined); this.onRuntimeFailure = options.onRuntimeFailure ?? (() => undefined); @@ -313,6 +317,10 @@ class IntegrationRegistryOwner { } public register(candidate: unknown): boolean { + if (!this.ownerIsCurrent()) { + this.fail('bundle_partial'); + return false; + } if (this.registryState === 'preparing' || this.registryState === 'activating') { this.fail('abi_mismatch'); return false; @@ -342,6 +350,10 @@ class IntegrationRegistryOwner { return false; } + if (!this.ownerIsCurrent()) { + this.fail('bundle_partial'); + return false; + } if (this.registryState !== 'collecting') return false; this.registrations.set( @@ -407,6 +419,23 @@ class IntegrationRegistryOwner { return !Number.isFinite(elapsed) || elapsed >= BOOT_DEADLINE_MS; } + private ownerIsCurrent(): boolean { + try { + return this.isCurrentOwner(); + } catch { + return false; + } + } + + private canContinue(phase: 'preparing' | 'activating'): boolean { + if (this.registryState !== phase) return false; + if (!this.ownerIsCurrent() || this.deadlineExpired()) { + this.fail('bundle_partial'); + return false; + } + return true; + } + private fail(reason: BootFailureReason): void { if ( this.registryState === 'committed' || @@ -531,6 +560,7 @@ class IntegrationRegistryOwner { } if ( !this.manifestValue || + !this.ownerIsCurrent() || this.deadlineExpired() || this.manifestValue.integrations.some((entry) => !this.registrations.has(entry.id)) ) { @@ -540,10 +570,7 @@ class IntegrationRegistryOwner { this.registryState = 'preparing'; for (const entry of this.manifestValue.integrations) { - if (this.registryState !== 'preparing' || this.deadlineExpired()) { - this.fail('bundle_partial'); - return this.fallbackResult(); - } + if (!this.canContinue('preparing')) return this.fallbackResult(); const scope = new DisposableStack(this.onDisposalError); const registration = this.registrations.get(entry.id); @@ -562,7 +589,7 @@ class IntegrationRegistryOwner { try { const { context, close } = this.createPreparationContext(entry.id, scope); - if (this.registryState !== 'preparing') { + if (!this.canContinue('preparing')) { close(); return this.fallbackResult(); } @@ -581,7 +608,7 @@ class IntegrationRegistryOwner { close(); prepared = pending; } - if (prepared === ABORTED || this.registryState !== 'preparing') { + if (prepared === ABORTED || !this.canContinue('preparing')) { this.fail('bundle_partial'); return this.fallbackResult(); } @@ -589,7 +616,7 @@ class IntegrationRegistryOwner { if (!preparedFields || typeof preparedFields.activate !== 'function') { throw new TypeError('prepare must return one exact activation module'); } - if (this.registryState !== 'preparing') return this.fallbackResult(); + if (!this.canContinue('preparing')) return this.fallbackResult(); this.prepared[recordIndex] = { id: entry.id, scope, @@ -603,18 +630,12 @@ class IntegrationRegistryOwner { return this.fallbackResult(); } - if (this.registryState !== 'preparing' || this.deadlineExpired()) { - this.fail('bundle_partial'); - return this.fallbackResult(); - } + if (!this.canContinue('preparing')) return this.fallbackResult(); } - if (this.registryState !== 'preparing') return this.fallbackResult(); + if (!this.canContinue('preparing')) return this.fallbackResult(); this.registryState = 'activating'; - if (this.deadlineExpired()) { - this.fail('bundle_partial'); - return this.fallbackResult(); - } + if (!this.canContinue('activating')) return this.fallbackResult(); let coreActivationOpen = true; const coreContext: CoreActivationContext = Object.freeze({ @@ -641,16 +662,10 @@ class IntegrationRegistryOwner { this.leaveOwnedCallback(); } - if (this.registryState !== 'activating' || this.deadlineExpired()) { - this.fail('bundle_partial'); - return this.fallbackResult(); - } + if (!this.canContinue('activating')) return this.fallbackResult(); for (const record of this.prepared) { - if (this.registryState !== 'activating' || this.deadlineExpired()) { - this.fail('bundle_partial'); - return this.fallbackResult(); - } + if (!this.canContinue('activating')) return this.fallbackResult(); let activationOpen = true; let afterCommitRegistered = false; @@ -693,18 +708,12 @@ class IntegrationRegistryOwner { this.leaveOwnedCallback(); } - if (this.registryState !== 'activating' || this.deadlineExpired()) { - this.fail('bundle_partial'); - return this.fallbackResult(); - } + if (!this.canContinue('activating')) return this.fallbackResult(); } // This final monotonic check closes the timer-task delay gap. A same-thread // activation that never returns cannot be preempted by JavaScript. - if (this.registryState !== 'activating' || this.deadlineExpired()) { - this.fail('bundle_partial'); - return this.fallbackResult(); - } + if (!this.canContinue('activating')) return this.fallbackResult(); this.registryState = 'publishing'; this.enterOwnedCallback(); diff --git a/crates/trusted-server-js/lib/src/kernel/runtime.ts b/crates/trusted-server-js/lib/src/kernel/runtime.ts new file mode 100644 index 000000000..843512bb9 --- /dev/null +++ b/crates/trusted-server-js/lib/src/kernel/runtime.ts @@ -0,0 +1,315 @@ +import { prepareQueue, publishQueue, type PublishedQueue } from '../core/queue'; +import { EMBEDDED_RELEASE_ID } from '../core/release'; +import { FALLBACK_REMOVED_FIELDS, LEGACY_TSJS_FIELDS } from '../core/surface'; + +import { buildFallbackBoot, buildKernelBoot, createFallbackFields, publicLog } from './fallback'; +import { + createIntegrationRegistry, + type BootFailureReason, + type CoreActivationContext, + type IntegrationBindings, + type IntegrationInstallResult, + type IntegrationRegistry, +} from './integration_registry'; + +export type RuntimeState = 'unclaimed' | 'installing' | 'kernel' | 'failed' | 'fallback'; + +type RuntimeTarget = object & { que?: unknown; boot?: unknown }; + +const TERMINAL_FIELDS = Object.freeze([ + 'version', + 'releaseId', + 'boot', + 'log', + 'addAdUnits', + 'requestAds', + 'diagnostics', + '_internal', + 'que', + ...LEGACY_TSJS_FIELDS, +]); + +function canClaimRuntimeTarget(target: RuntimeTarget): boolean { + try { + if (Object.getOwnPropertyDescriptor(target, '_registerIntegration')) return false; + for (const key of TERMINAL_FIELDS) { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + if (descriptor && !descriptor.configurable) return false; + } + return true; + } catch { + return false; + } +} + +function restoreOwnProperty( + target: RuntimeTarget, + key: string, + descriptor: PropertyDescriptor | undefined +): void { + try { + if (descriptor) Object.defineProperty(target, key, descriptor); + else Reflect.deleteProperty(target, key); + } catch { + // A hostile publisher Proxy cannot escape startup or block the remaining cleanup. + } +} + +export interface RuntimeKernel { + readonly addAdUnits: (units: unknown) => unknown; + readonly requestAds: (options?: unknown) => Promise; + readonly diagnostics: Readonly; +} + +export interface RuntimeOptions { + readonly target: RuntimeTarget; + /** Server assertion only; every decision and published value is bound to the build stamp. */ + readonly releaseId: string; + readonly manifest: unknown; + readonly knownIntegrationIds: readonly string[]; + readonly boot?: unknown; + readonly now?: () => number; + readonly getBindings?: (id: string) => IntegrationBindings; + readonly activateCore?: (context: CoreActivationContext) => void; + readonly kernel: RuntimeKernel; +} + +export interface Runtime { + readonly state: RuntimeState; + readonly generation: object; + readonly start: () => boolean; + readonly registerIntegration: (registration: unknown) => boolean; + readonly install: () => Promise; + readonly dispose: () => void; +} + +class RuntimeOwner implements Runtime { + public readonly generation = Object.freeze({}); + private readonly options: RuntimeOptions; + private readonly registrationHandshake = (candidate: unknown): boolean => + this.registerIntegration(candidate); + private runtimeState: RuntimeState = 'unclaimed'; + private registry: IntegrationRegistry | undefined; + private ingress: unknown[] | undefined; + private installPromise: Promise | undefined; + private kernelBoot: Readonly | undefined; + private fallbackBoot: Readonly | undefined; + + public constructor(options: RuntimeOptions) { + this.options = options; + } + + public get state(): RuntimeState { + return this.runtimeState; + } + + public start(): boolean { + if (this.runtimeState !== 'unclaimed') return false; + let queueDescriptor: PropertyDescriptor | undefined; + let bootDescriptor: PropertyDescriptor | undefined; + let registrationDescriptor: PropertyDescriptor | undefined; + let claimMutationStarted = false; + try { + if (!canClaimRuntimeTarget(this.options.target)) return false; + const startedAtMs = (this.options.now ?? (() => performance.now()))(); + queueDescriptor = Object.getOwnPropertyDescriptor(this.options.target, 'que'); + bootDescriptor = Object.getOwnPropertyDescriptor(this.options.target, 'boot'); + registrationDescriptor = Object.getOwnPropertyDescriptor( + this.options.target, + '_registerIntegration' + ); + if (registrationDescriptor) return false; + claimMutationStarted = true; + if ( + !bootDescriptor || + !('value' in bootDescriptor) || + typeof bootDescriptor.value !== 'object' || + bootDescriptor.value === null + ) { + Object.defineProperty(this.options.target, 'boot', { + configurable: true, + enumerable: true, + value: {}, + writable: true, + }); + } + this.ingress = prepareQueue(this.options.target); + const bootCandidate = this.bootCandidate(); + this.fallbackBoot = buildFallbackBoot(EMBEDDED_RELEASE_ID, bootCandidate); + this.registry = createIntegrationRegistry({ + manifest: + this.options.releaseId === EMBEDDED_RELEASE_ID ? this.options.manifest : undefined, + releaseId: EMBEDDED_RELEASE_ID, + knownIntegrationIds: this.options.knownIntegrationIds, + startedAtMs, + ...(this.options.now ? { now: this.options.now } : {}), + ...(this.options.getBindings ? { getBindings: this.options.getBindings } : {}), + isCurrentOwner: () => this.ownsRegistrationHandshake(), + }); + if (this.registry.manifest) { + this.kernelBoot = buildKernelBoot( + EMBEDDED_RELEASE_ID, + this.registry.manifest, + bootCandidate + ); + } + Object.defineProperty(this.options.target, '_registerIntegration', { + configurable: true, + enumerable: false, + value: this.registrationHandshake, + writable: false, + }); + if (!this.ownsRegistrationHandshake()) { + throw new Error('Runtime owner handshake changed during claim'); + } + this.runtimeState = 'installing'; + return true; + } catch { + try { + this.registry?.dispose(); + } catch { + // Disposal is best-effort while unwinding a failed claim. + } + if (claimMutationStarted) { + restoreOwnProperty(this.options.target, 'que', queueDescriptor); + restoreOwnProperty(this.options.target, 'boot', bootDescriptor); + restoreOwnProperty(this.options.target, '_registerIntegration', registrationDescriptor); + } + this.runtimeState = 'unclaimed'; + this.registry = undefined; + this.ingress = undefined; + this.kernelBoot = undefined; + this.fallbackBoot = undefined; + return false; + } + } + + public registerIntegration(registration: unknown): boolean { + if (this.runtimeState !== 'installing' || !this.ownsRegistrationHandshake()) return false; + return this.registry?.register(registration) ?? false; + } + + public install(): Promise { + if (this.installPromise) return this.installPromise; + if (!this.registry || !this.ingress || this.runtimeState !== 'installing') { + return Promise.resolve(Object.freeze({ state: 'fallback', reason: 'bundle_partial' })); + } + if (!this.kernelBoot) { + this.registry.dispose(); + const result = Object.freeze({ state: 'fallback' as const, reason: 'abi_mismatch' as const }); + this.runtimeState = 'failed'; + this.commitFallback(result.reason); + this.installPromise = Promise.resolve(result); + return this.installPromise; + } + let published: PublishedQueue | undefined; + this.installPromise = this.registry + .install({ + activateCore: this.options.activateCore ?? (() => undefined), + publish: () => { + if (!this.ownsRegistrationHandshake()) { + throw new Error('Runtime owner generation changed'); + } + published = publishQueue( + this.options.target, + this.ingress as unknown[], + this.kernelFields(), + LEGACY_TSJS_FIELDS + ); + }, + drainPreload: () => published?.drain(), + }) + .then((result) => { + if (result.state === 'kernel') { + this.runtimeState = 'kernel'; + return result; + } + this.runtimeState = 'failed'; + this.commitFallback(result.reason); + return result; + }); + return this.installPromise; + } + + public dispose(): void { + this.registry?.dispose(); + } + + private kernelFields(): Readonly> { + const fields: Record = {}; + Object.defineProperties(fields, { + version: { enumerable: true, value: '1.0.0' }, + releaseId: { enumerable: true, value: EMBEDDED_RELEASE_ID }, + boot: { + enumerable: true, + value: this.kernelBoot, + }, + log: { enumerable: true, value: publicLog }, + _registerIntegration: { enumerable: true, value: () => false }, + addAdUnits: { enumerable: true, value: this.options.kernel.addAdUnits }, + requestAds: { enumerable: true, value: this.options.kernel.requestAds }, + diagnostics: { enumerable: true, value: this.options.kernel.diagnostics }, + _internal: { + enumerable: false, + value: Object.freeze({ state: 'kernel', releaseId: EMBEDDED_RELEASE_ID }), + }, + }); + return Object.freeze(fields); + } + + private commitFallback(reason: BootFailureReason): void { + if (!this.ownsRegistrationHandshake() || !this.ingress) return; + const published = publishQueue( + this.options.target, + this.ingress, + createFallbackFields({ + releaseId: EMBEDDED_RELEASE_ID, + reason, + boot: this.fallbackBoot, + }), + FALLBACK_REMOVED_FIELDS + ); + this.runtimeState = 'fallback'; + published.drain(); + } + + private ownsRegistrationHandshake(): boolean { + try { + const descriptor = Object.getOwnPropertyDescriptor( + this.options.target, + '_registerIntegration' + ); + return ( + descriptor !== undefined && + 'value' in descriptor && + descriptor.value === this.registrationHandshake && + descriptor.configurable === true && + descriptor.enumerable === false && + descriptor.writable === false + ); + } catch { + return false; + } + } + + private bootCandidate(): unknown { + if (this.options.boot !== undefined) return this.options.boot; + const descriptor = Object.getOwnPropertyDescriptor(this.options.target, 'boot'); + return descriptor && 'value' in descriptor ? descriptor.value : undefined; + } +} + +/** Create one dormant runtime owner; `start` performs the test-only claim. */ +export function createRuntime(options: RuntimeOptions): Runtime { + const owner = new RuntimeOwner(options); + return Object.freeze({ + get state() { + return owner.state; + }, + generation: owner.generation, + start: () => owner.start(), + registerIntegration: (registration: unknown) => owner.registerIntegration(registration), + install: () => owner.install(), + dispose: () => owner.dispose(), + }); +} 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..8f3b19273 --- /dev/null +++ b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs @@ -0,0 +1,104 @@ +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'); + +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 = new JSDOM('', { runScripts: 'outside-only' }); + 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 = new JSDOM('', { runScripts: 'outside-only' }); + 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 = new JSDOM('', { runScripts: 'outside-only' }); + 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..4194aad9a --- /dev/null +++ b/crates/trusted-server-js/lib/test/build/release-v1.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + RELEASE_SENTINEL, + computeReleaseId, + stampRelease, + validateStampedRelease, +} from '../../scripts/release-v1.mjs'; + +const bundle = (id, logical) => ({ id, bytes: Buffer.from(`${logical}${RELEASE_SENTINEL}`) }); + +test('release id changes with logical bytes and bundle order', () => { + const base = [bundle('core', 'a'), bundle('gpt', 'b')]; + assert.notEqual(computeReleaseId(base), computeReleaseId([bundle('core', 'changed'), base[1]])); + assert.notEqual(computeReleaseId(base), computeReleaseId([base[1], base[0]])); +}); + +test('sentinel multiplicity and remnants fail closed', () => { + assert.throws(() => computeReleaseId([bundle('core', RELEASE_SENTINEL)]), /exactly one/); + assert.throws( + () => computeReleaseId([{ id: 'core', 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.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 4dfea74a4..25f12e7fb 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GoogletagAdapter } from '../../src/adapters/googletag'; import type { CaptureMessageListener, MessagingAdapter } from '../../src/adapters/messaging'; @@ -6,6 +6,7 @@ import type { PrebidAdapter } from '../../src/adapters/prebid'; import { createBrowserComposition, createNoopBrowserComposition, + createTestBrowserRuntimeComposition, } from '../../src/composition/browser'; function createTarget() { @@ -20,6 +21,8 @@ function createTarget() { } describe('browser composition', () => { + afterEach(() => vi.useRealTimers()); + it('constructs live adapters without changing production globals', () => { const target = createTarget(); const composition = createBrowserComposition({ target }); @@ -84,4 +87,155 @@ describe('browser composition', () => { expect(() => composition.adapters.messaging.installCaptureListener(listener)()).not.toThrow(); expect(listener).not.toHaveBeenCalled(); }); + + 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: { + version: 1, + releaseId: 'a'.repeat(64), + integrations: [{ id: 'test', required: true }], + }, + knownIntegrationIds: Object.freeze(['test']), + boot: { + 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 } }, + }, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }, + { + adapters: { + googletag: { bindingStatus: () => 'pending' }, + prebid: { bindingStatus: () => 'pending' }, + messaging: { installCaptureListener: () => vi.fn() }, + }, + coreActivations: { + bridgeRecognizer: ({ onDispose }, adapters) => { + expect(Object.isFrozen(adapters)).toBe(true); + onDispose(() => order.push('dispose-bridge')); + order.push('bridge'); + }, + 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({ + id: 'test', + release: 'a'.repeat(64), + prepare: ({ onDispose }: { onDispose(callback: () => void): void }) => { + onDispose(() => order.push('dispose-module')); + return { activate: () => order.push('module') }; + }, + }) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['bridge', 'gpt', 'module']); + + composition.runtime.dispose(); + expect(order).toEqual([ + 'bridge', + 'gpt', + 'module', + 'dispose-module', + 'dispose-gpt', + 'dispose-bridge', + ]); + expect(Object.isFrozen(composition)).toBe(true); + expect(Object.isFrozen(composition.runtime)).toBe(true); + }); + + 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 timerActivation = vi.fn(() => setTimeout(vi.fn(), 1)); + const latePreparation = vi.fn(); + const target = {}; + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId: 'a'.repeat(64), + manifest: { + version: 1, + releaseId: 'a'.repeat(64), + integrations: [{ id: 'missing', required: true }], + }, + knownIntegrationIds: Object.freeze(['missing']), + boot: { + 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 } }, + }, + getBindings: serviceConstruction, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }, + { + adapters: { + googletag: { bindingStatus: adapterActivation }, + prebid: { bindingStatus: adapterActivation }, + messaging: { installCaptureListener: listenerActivation }, + }, + coreActivations: { + bridgeRecognizer: timerActivation, + correctnessGptListeners: adapterActivation, + }, + } + ); + + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(vi.getTimerCount()).toBe(0); + + expect( + (target as { _registerIntegration(value: unknown): boolean })._registerIntegration({ + id: 'missing', + release: 'a'.repeat(64), + prepare: latePreparation, + }) + ).toBe(false); + await vi.runAllTimersAsync(); + + expect(serviceConstruction).not.toHaveBeenCalled(); + expect(adapterActivation).not.toHaveBeenCalled(); + expect(listenerActivation).not.toHaveBeenCalled(); + expect(timerActivation).not.toHaveBeenCalled(); + expect(latePreparation).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); }); 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/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/eslint/no-adtech-globals.test.mjs b/crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs index 14df05812..1770c5613 100644 --- 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 @@ -1,9 +1,10 @@ import assert from 'node:assert/strict'; -import { readdir } from 'node:fs/promises'; +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, { LEGACY_ADTECH_GLOBAL_ALLOWLIST, @@ -62,6 +63,47 @@ function assertRejected(source, filename) { 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);', @@ -184,7 +226,6 @@ test('temporary allowlists are exact, narrow, and inventoried for Task 22 remova 'src/integrations/prebid/index.ts', ]); assert.deepEqual(LEGACY_RESTRICTED_IMPORT_ALLOWLIST, [ - 'src/core/auction.ts', 'src/core/request.ts', 'src/integrations/gpt/index.ts', 'src/integrations/prebid/index.ts', @@ -326,6 +367,16 @@ test('restricted paths enforce dependency direction and exact target-file exempt ); }); +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, 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 b09a37084..49b2769cb 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 @@ -4,7 +4,7 @@ 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/integrations/aps/generated/renderer_validator_v1'; +import { classifyApsRendererV1 } from '../../../src/core/contracts/generated/renderer_validator_v1'; import { APS_RENDERER_PATH, APS_RENDERER_SANDBOX, diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index 323180e8e..587cf59cf 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -21,6 +21,17 @@ const BOOTSTRAP_SOURCE = readFileSync( path.resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), 'utf8' ); +const FALLBACK_ENTRY_SOURCE = readFileSync( + path.resolve(process.cwd(), 'src/integrations/gpt/bootstrap_fallback.ts'), + 'utf8' +); +const RELEASE_SENTINEL = '__TSJS_RELEASE_ID_SENTINEL_V1__'; +const TEST_RELEASE_ID = 'a'.repeat(64); + +async function runProposedFallback(): Promise { + vi.resetModules(); + await import('../../../src/integrations/gpt/bootstrap_fallback'); +} // The command queue the bootstrap pushes into: a real array once GPT has // loaded, or the bare `push`-only stub GPT installs before then. @@ -248,3 +259,96 @@ describe('gpt_bootstrap.js fallback', () => { expect(mockPubads.enableSingleRequest).not.toHaveBeenCalled(); }); }); + +describe('generated terminal bootstrap fallback proposal', () => { + afterEach(() => { + delete (window as TestWindow).tsjs; + delete (window as unknown as Record).tsjs_gpt_bootstrap_fallback; + }); + + it('uses the build-stamped release contract and remains dormant until Task 19', () => { + expect(FALLBACK_ENTRY_SOURCE).toContain('EMBEDDED_RELEASE_ID'); + expect(FALLBACK_ENTRY_SOURCE).not.toContain(RELEASE_SENTINEL); + expect(BOOTSTRAP_SOURCE).toContain('ts.adInit'); + expect(BOOTSTRAP_SOURCE).not.toContain(RELEASE_SENTINEL); + }); + + it('executes the complete terminal shell, drains once, and creates no callable global', async () => { + const calls: string[] = []; + const target = { + que: [ + function (this: unknown) { + expect(this).toBe(target); + calls.push('queued'); + }, + ], + boot: { + failureReason: 'abi_mismatch', + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'boot', + results: [{ slot: 'known', outcome: 'no_bid' }], + }, + bids: [], + }, + }, + }; + (window as unknown as { tsjs: unknown }).tsjs = target; + + await runProposedFallback(); + const api = window.tsjs as unknown as { + version: string; + releaseId: string; + que: unknown[]; + requestAds(options?: unknown): Promise; + _internal: unknown; + _registerIntegration(value: unknown): boolean; + adInit?: unknown; + }; + + expect(calls).toEqual(['queued']); + expect(api.version).toBe('1.0.0'); + expect(api.releaseId).toBe(TEST_RELEASE_ID); + expect(api._internal).toEqual({ + state: 'fallback', + releaseId: TEST_RELEASE_ID, + reason: 'bundle_partial', + }); + expect(Object.isFrozen(api.que)).toBe(true); + expect(api._registerIntegration({ prepare: vi.fn() })).toBe(false); + expect(api.adInit).toBeUndefined(); + expect( + (window as unknown as Record).tsjs_gpt_bootstrap_fallback + ).toBeUndefined(); + await expect(api.requestAds({ slots: ['known'] })).resolves.toEqual({ + slots: [ + { + slot: 'known', + path: 'primary', + outcome: 'failed', + reason: 'bundle_partial', + }, + ], + }); + }); + + it('does not execute hostile boot accessors before substituting safe boot', async () => { + const getter = vi.fn(); + const target = { que: [] as unknown[] }; + Object.defineProperty(target, 'boot', { configurable: true, enumerable: true, get: getter }); + (window as unknown as { tsjs: unknown }).tsjs = target; + + await expect(runProposedFallback()).resolves.toBeUndefined(); + + expect(getter).not.toHaveBeenCalled(); + expect((window.tsjs as unknown as { boot: unknown } | undefined)?.boot).toMatchObject({ + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'fallback', results: [] }, + bids: [], + }, + }); + }); +}); 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..ddd1b60b3 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -0,0 +1,1356 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; + +import { + AdUnitRegistrationError, + RequestAdsInputError, + TsjsUnavailableError, + type AdUnitRegistrationErrorCode, +} from '../../src/kernel/fallback'; +import { createRuntime } from '../../src/kernel/runtime'; + +const RELEASE = 'a'.repeat(64); + +function boot(results: readonly object[] = []) { + return { + 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 } }, + }; +} + +function manifest(ids: readonly string[]) { + return { + version: 1, + releaseId: RELEASE, + integrations: ids.map((id) => ({ id, required: true })), + }; +} + +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()); + + 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('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({ + id: 'gpt', + release: 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('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({ + id: 'gpt', + release: 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) => { + 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(); + await expect(runtime.install()).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', + release: RELEASE, + prepare: vi.fn(), + } + ) + ).toBe(false); + }); + + it('removes legacy and fallback-forbidden surfaces at terminal publication', 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.publisher).toEqual({ retained: true }); + }); + + 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({ + id: 'gpt', + release: 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({ + id: 'gpt', + release: 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( + { id: 'gpt', release: 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({ + id: 'gpt', + release: 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'); + 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', { id: 'gpt', release: 'b'.repeat(64), prepare: vi.fn() }], + ['unknown id', { id: 'aps', release: 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 = { id: 'gpt', release: 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({ id: 'gpt', release: 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({ + id: 'gpt', + release: 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({ + id: 'gpt', + release: 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: [] }, + 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: [] }, + 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: [] }, + 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/vitest.config.ts b/crates/trusted-server-js/lib/vitest.config.ts index 7a6941faf..446c3146c 100644 --- a/crates/trusted-server-js/lib/vitest.config.ts +++ b/crates/trusted-server-js/lib/vitest.config.ts @@ -3,6 +3,9 @@ import path from 'node:path'; import { configDefaults, defineConfig } from 'vitest/config'; export default defineConfig({ + define: { + __TSJS_EMBEDDED_RELEASE_ID_V1__: JSON.stringify('a'.repeat(64)), + }, resolve: { alias: { // prebid.js doesn't expose src/adapterManager.js via its package @@ -28,6 +31,7 @@ export default defineConfig({ ...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. diff --git a/crates/trusted-server-js/src/bundle.rs b/crates/trusted-server-js/src/bundle.rs index 7ddc060ab..71048ab08 100644 --- a/crates/trusted-server-js/src/bundle.rs +++ b/crates/trusted-server-js/src/bundle.rs @@ -6,6 +6,20 @@ use sha2::{Digest as _, Sha256}; include!(concat!(env!("OUT_DIR"), "/tsjs_modules.rs")); +/// 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] diff --git a/crates/trusted-server-js/src/lib.rs b/crates/trusted-server-js/src/lib.rs index 2c816b154..aaa63de6e 100644 --- a/crates/trusted-server-js/src/lib.rs +++ b/crates/trusted-server-js/src/lib.rs @@ -6,5 +6,6 @@ pub mod bundle; pub use bundle::{ - all_module_ids, concatenate_modules, concatenated_hash, module_bundle, single_module_hash, + all_module_ids, concatenate_modules, concatenated_hash, gpt_bootstrap_fallback_bundle, + module_bundle, release_id, single_module_hash, }; diff --git a/scripts/generate-aps-renderer-contract.mjs b/scripts/generate-aps-renderer-contract.mjs index 53c5e8b7b..d835b8144 100644 --- a/scripts/generate-aps-renderer-contract.mjs +++ b/scripts/generate-aps-renderer-contract.mjs @@ -16,7 +16,7 @@ const es5Path = path.join( ); const typescriptPath = path.join( repositoryRoot, - 'crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts' + 'crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_v1.ts' ); const [schemaText, corpusText] = await Promise.all([ From 7450c0d80d85e53d99e8ac145a2ba52470236869 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:10:45 -0700 Subject: [PATCH 025/194] Add runtime and navigation session ownership --- .../lib/src/composition/browser.ts | 196 +++++- .../lib/src/kernel/identity.ts | 181 +++++ .../lib/src/kernel/runtime.ts | 47 +- .../lib/src/kernel/sessions.ts | 616 ++++++++++++++++++ .../lib/src/services/context.ts | 279 ++++++++ .../lib/src/services/projections.ts | 189 ++++++ .../lib/test/composition/browser.test.ts | 269 ++++++++ .../lib/test/kernel/identity.test.ts | 170 +++++ .../lib/test/kernel/sessions.test.ts | 332 ++++++++++ .../lib/test/services/context.test.ts | 193 ++++++ .../lib/test/services/projections.test.ts | 285 ++++++++ 11 files changed, 2754 insertions(+), 3 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/kernel/identity.ts create mode 100644 crates/trusted-server-js/lib/src/kernel/sessions.ts create mode 100644 crates/trusted-server-js/lib/src/services/context.ts create mode 100644 crates/trusted-server-js/lib/src/services/projections.ts create mode 100644 crates/trusted-server-js/lib/test/kernel/identity.test.ts create mode 100644 crates/trusted-server-js/lib/test/kernel/sessions.test.ts create mode 100644 crates/trusted-server-js/lib/test/services/context.test.ts create mode 100644 crates/trusted-server-js/lib/test/services/projections.test.ts diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 6d5ae6b62..21d79b3dc 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -16,8 +16,20 @@ import { type PrebidAdapter, type PrebidGlobalTarget, } from '../adapters/prebid'; +import { parseBrowserAuctionProjectionV1 } from '../core/contracts/auction_projection'; +import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; +import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; +import { createRuntimeSession } from '../kernel/sessions'; import type { CoreActivationContext } from '../kernel/integration_registry'; import { createRuntime, type Runtime, type RuntimeOptions } from '../kernel/runtime'; +import { createAuctionContextRegistry, type AuctionContextRegistry } from '../services/context'; +import { + createPageBidsController, + type PageBidsController, + type PreparedProjectionSlots, + type ProjectionSlotRegistry, + prepareInitialAuctionProjection, +} from '../services/projections'; export interface BrowserAdapters { readonly googletag: GoogletagAdapter; @@ -38,6 +50,14 @@ export interface BrowserCompositionOptions { export interface BrowserRuntimeComposition extends BrowserComposition { readonly runtime: Runtime; + /** 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; } export interface BrowserCoreActivations { @@ -53,6 +73,111 @@ export interface BrowserCoreActivations { export interface TestBrowserRuntimeCompositionOptions extends BrowserCompositionOptions { readonly coreActivations: BrowserCoreActivations; + readonly createIdentityIssuerForTest?: NavigationIdentityIssuerFactory; + readonly admittedProgrammaticSlotsForTest?: readonly string[]; +} + +interface AcceptedBrowserBoot { + readonly auctionProjection: object; + readonly cachePolicy?: unknown; + readonly manifest: { + readonly integrations: readonly { readonly id: string }[]; + }; +} + +class BrowserProjectionSlotLedger { + private readonly slots = new Map(); + + public bind( + navigation: NonNullable + ): ProjectionSlotRegistry { + return Object.freeze({ + prepareProjectionSlots: ( + ownerGeneration: object, + slots: readonly string[], + maximumActiveSlots: number + ): PreparedProjectionSlots | undefined => { + const ownedSlots = Object.freeze([...slots]); + if ( + ownerGeneration !== navigation.generation || + !navigation.isCurrent() || + ownedSlots.some((slot) => typeof slot !== 'string') || + new Set(ownedSlots).size !== ownedSlots.length || + this.slots.size + ownedSlots.length > maximumActiveSlots || + ownedSlots.some((slot) => this.slots.has(slot)) + ) { + return undefined; + } + let active = false; + let ownerDisposed = false; + const rollback = (): void => { + if (!active) return; + active = false; + for (const slot of ownedSlots) { + if (this.slots.get(slot) === ownerGeneration) this.slots.delete(slot); + } + }; + return Object.freeze({ + ownerGeneration, + commit: (): boolean => { + if ( + active || + ownerDisposed || + !navigation.isCurrent() || + this.slots.size + ownedSlots.length > maximumActiveSlots || + ownedSlots.some((slot) => this.slots.has(slot)) + ) { + return false; + } + navigation.onDispose('projection-slots', () => { + ownerDisposed = true; + rollback(); + }); + if (ownerDisposed || !navigation.isCurrent()) return false; + for (const slot of ownedSlots) this.slots.set(slot, ownerGeneration); + active = true; + return true; + }, + rollback, + }); + }, + }); + } + + public seed( + navigation: NonNullable, + slots: readonly string[] + ): boolean { + const reservation = this.bind(navigation).prepareProjectionSlots( + navigation.generation, + slots, + 256 + ); + return reservation?.commit() ?? false; + } + + public admitProgrammatic( + navigation: NonNullable, + slots: readonly string[] + ): boolean { + const reservation = this.bind(navigation).prepareProjectionSlots( + navigation.generation, + slots, + 256 + ); + return reservation?.commit() ?? false; + } + + public snapshotForTest(): readonly string[] { + return Object.freeze([...this.slots.keys()]); + } +} + +function projectionSlots(projection: object): readonly string[] { + const accepted = projection as { + readonly auction: { readonly results: readonly { readonly slot: string }[] }; + }; + return Object.freeze(accepted.auction.results.map(({ slot }) => slot)); } /** @@ -105,13 +230,80 @@ export function createTestBrowserRuntimeComposition( compositionOptions: TestBrowserRuntimeCompositionOptions ): BrowserRuntimeComposition { const composition = createBrowserComposition(compositionOptions); + let runtimeSession: RuntimeSession | undefined; + let projectionSlotLedger: BrowserProjectionSlotLedger | undefined; + let auctionContextRegistry: AuctionContextRegistry | undefined; + let projectionParser: ((candidate: unknown) => object | undefined) | undefined; const runtime = createRuntime({ ...runtimeOptions, + activateOwner: (context) => { + const boot = context.boot as unknown as AcceptedBrowserBoot; + 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 session = createRuntimeSession({ + createIdentityIssuer: + compositionOptions.createIdentityIssuerForTest ?? createBrowserNavigationIdentityIssuer, + interfaces: Object.freeze({ adapters: composition.adapters }), + }); + context.onDispose(() => { + session.dispose(); + if (runtimeSession === session) { + runtimeSession = undefined; + projectionSlotLedger = undefined; + auctionContextRegistry = undefined; + projectionParser = undefined; + } + }); + const navigation = session.startInitialNavigation(initialProjection); + if (!navigation.ok) throw new Error(navigation.reason); + + const ledger = new BrowserProjectionSlotLedger(); + if ( + !ledger.admitProgrammatic( + navigation.value, + compositionOptions.admittedProgrammaticSlotsForTest ?? [] + ) + ) { + throw new Error('Initial programmatic slots exceed the shared registry'); + } + if (!ledger.seed(navigation.value, projectionSlots(initialProjection))) { + throw new Error('Initial projection slots exceed the shared registry'); + } + const contextRegistry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(boot.manifest.integrations.map(({ id }) => id)), + runtimeOwner: session, + }); + runtimeSession = session; + projectionSlotLedger = ledger; + auctionContextRegistry = contextRegistry; + projectionParser = parseProjection; + return runtimeOptions.activateOwner?.(context); + }, activateCore: (context) => { compositionOptions.coreActivations.bridgeRecognizer(context, composition.adapters); compositionOptions.coreActivations.correctnessGptListeners(context, composition.adapters); - runtimeOptions.activateCore?.(context); + return runtimeOptions.activateCore?.(context); + }, + }); + return Object.freeze({ + adapters: composition.adapters, + runtime, + runtimeSessionForTest: () => runtimeSession, + pageBidsControllerForTest: (): PageBidsController | undefined => { + const navigation = runtimeSession?.currentNavigation; + if (!navigation || !projectionSlotLedger || !projectionParser) return undefined; + return createPageBidsController({ + navigation, + parseProjection: projectionParser, + slotRegistry: projectionSlotLedger.bind(navigation), + }); }, + projectionSlotsForTest: () => projectionSlotLedger?.snapshotForTest(), + auctionContextRegistryForTest: () => auctionContextRegistry, }); - return Object.freeze({ adapters: composition.adapters, runtime }); } diff --git a/crates/trusted-server-js/lib/src/kernel/identity.ts b/crates/trusted-server-js/lib/src/kernel/identity.ts new file mode 100644 index 000000000..a366dc485 --- /dev/null +++ b/crates/trusted-server-js/lib/src/kernel/identity.ts @@ -0,0 +1,181 @@ +const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const IDENTITY_FAILURE = Object.freeze({ + ok: false as const, + reason: 'identity_generation_failed' as const, +}); + +/** Browser-compatible source for cryptographically secure random bytes. */ +export type RandomValuesSource = (target: Uint8Array) => Uint8Array; + +/** The sole failure reported when secure identity generation is unavailable. */ +export type IdentityGenerationFailure = typeof IDENTITY_FAILURE; + +/** Result of creating or minting one browser identity. */ +export type IdentityGenerationResult = + Readonly<{ ok: true; value: T }> | IdentityGenerationFailure; + +/** Observational identity failure callback that never receives identity material. */ +export type IdentityFailureObserver = (reason: 'identity_generation_failed') => void; + +/** Navigation-local issuer for non-repeating attempt identities. */ +export interface NavigationIdentityIssuer { + readonly mintAttemptId: () => IdentityGenerationResult; + /** Return a frozen ordinal copy for deterministic tests. */ + readonly snapshotOrdinalForTest: () => readonly [number, number]; +} + +/** Test-only controls for a deterministic navigation identity issuer. */ +export interface TestNavigationIdentityIssuerOptions { + readonly getRandomValues?: RandomValuesSource; + readonly initialOrdinal?: readonly [number, number]; + readonly onFailure?: IdentityFailureObserver; +} + +function reportFailure(observer?: IdentityFailureObserver): IdentityGenerationFailure { + try { + observer?.('identity_generation_failed'); + } catch { + // Identity failure observation cannot change the typed failure. + } + return IDENTITY_FAILURE; +} + +function randomBytes( + length: number, + source: RandomValuesSource | undefined, + observer?: IdentityFailureObserver +): IdentityGenerationResult { + if (!source) return reportFailure(observer); + try { + const bytes = new Uint8Array(new ArrayBuffer(length)); + const returned = source(bytes); + if (returned !== bytes || bytes.length !== length) return reportFailure(observer); + return Object.freeze({ ok: true as const, value: bytes }); + } catch { + return reportFailure(observer); + } +} + +function encodeBase64Url(bytes: Uint8Array): string { + let encoded = ''; + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index] ?? 0; + const second = bytes[index + 1] ?? 0; + const third = bytes[index + 2] ?? 0; + const remaining = bytes.length - index; + const combined = (first << 16) | (second << 8) | third; + encoded += BASE64URL_ALPHABET[(combined >>> 18) & 63]; + encoded += BASE64URL_ALPHABET[(combined >>> 12) & 63]; + if (remaining > 1) encoded += BASE64URL_ALPHABET[(combined >>> 6) & 63]; + if (remaining > 2) encoded += BASE64URL_ALPHABET[combined & 63]; + } + return encoded; +} + +function validUnsignedWord(value: number): boolean { + return Number.isInteger(value) && value >= 0 && value <= 0xffff_ffff; +} + +function createNavigationIdentityIssuer( + source: RandomValuesSource | undefined, + initialOrdinal: readonly [number, number], + observer?: IdentityFailureObserver +): IdentityGenerationResult { + if (!validUnsignedWord(initialOrdinal[0]) || !validUnsignedWord(initialOrdinal[1])) { + return reportFailure(observer); + } + const prefixResult = randomBytes(8, source, observer); + if (!prefixResult.ok) return prefixResult; + const prefix = prefixResult.value; + let highWord = initialOrdinal[0] >>> 0; + let lowWord = initialOrdinal[1] >>> 0; + + const issuer: NavigationIdentityIssuer = Object.freeze({ + mintAttemptId(): IdentityGenerationResult { + if (highWord === 0xffff_ffff && lowWord === 0xffff_ffff) { + return reportFailure(observer); + } + if (lowWord === 0xffff_ffff) { + highWord = (highWord + 1) >>> 0; + lowWord = 0; + } else { + lowWord = (lowWord + 1) >>> 0; + } + + const identity = new Uint8Array(16); + identity.set(prefix, 0); + const view = new DataView(identity.buffer); + view.setUint32(8, highWord, false); + view.setUint32(12, lowWord, false); + return Object.freeze({ ok: true, value: `a1_${encodeBase64Url(identity)}` }); + }, + snapshotOrdinalForTest: () => Object.freeze([highWord, lowWord] as const), + }); + return Object.freeze({ ok: true, value: issuer }); +} + +function browserRandomSource(): RandomValuesSource | undefined { + try { + if (typeof crypto !== 'object' || typeof crypto.getRandomValues !== 'function') { + return undefined; + } + return (target: Uint8Array) => { + crypto.getRandomValues(target); + return target; + }; + } catch { + return undefined; + } +} + +/** Create a production navigation issuer from browser Web Crypto only. */ +export function createBrowserNavigationIdentityIssuer(): IdentityGenerationResult { + return createNavigationIdentityIssuer(browserRandomSource(), [0, 0]); +} + +/** Create a deterministic navigation issuer for tests only. */ +export function createTestNavigationIdentityIssuer( + options: TestNavigationIdentityIssuerOptions +): IdentityGenerationResult { + return createNavigationIdentityIssuer( + options.getRandomValues, + options.initialOrdinal ?? [0, 0], + options.onFailure + ); +} + +function mintFreshIdentity( + prefix: 't1_' | 'n1_', + source: RandomValuesSource | undefined, + observer?: IdentityFailureObserver +): IdentityGenerationResult { + const bytes = randomBytes(16, source, observer); + if (!bytes.ok) return bytes; + return Object.freeze({ ok: true, value: `${prefix}${encodeBase64Url(bytes.value)}` }); +} + +/** Mint one production lifecycle ticket from sixteen browser Web Crypto bytes. */ +export function mintBrowserLifecycleTicket(): IdentityGenerationResult { + return mintFreshIdentity('t1_', browserRandomSource()); +} + +/** Mint one production renderer nonce from sixteen browser Web Crypto bytes. */ +export function mintBrowserRendererNonce(): IdentityGenerationResult { + return mintFreshIdentity('n1_', browserRandomSource()); +} + +/** Mint one deterministic lifecycle ticket for tests only. */ +export function mintTestLifecycleTicket( + source: RandomValuesSource, + observer?: IdentityFailureObserver +): IdentityGenerationResult { + return mintFreshIdentity('t1_', source, observer); +} + +/** Mint one deterministic renderer nonce for tests only. */ +export function mintTestRendererNonce( + source: RandomValuesSource, + observer?: IdentityFailureObserver +): IdentityGenerationResult { + return mintFreshIdentity('n1_', source, observer); +} diff --git a/crates/trusted-server-js/lib/src/kernel/runtime.ts b/crates/trusted-server-js/lib/src/kernel/runtime.ts index 843512bb9..4f5bbc8c4 100644 --- a/crates/trusted-server-js/lib/src/kernel/runtime.ts +++ b/crates/trusted-server-js/lib/src/kernel/runtime.ts @@ -61,6 +61,12 @@ export interface RuntimeKernel { readonly diagnostics: Readonly; } +/** Frozen activation boundary for document-lifetime owners created after preparation. */ +export interface RuntimeOwnerActivationContext extends CoreActivationContext { + readonly boot: Readonly; + readonly generation: object; +} + export interface RuntimeOptions { readonly target: RuntimeTarget; /** Server assertion only; every decision and published value is bound to the build stamp. */ @@ -70,6 +76,7 @@ export interface RuntimeOptions { readonly boot?: unknown; readonly now?: () => number; readonly getBindings?: (id: string) => IntegrationBindings; + readonly activateOwner?: (context: RuntimeOwnerActivationContext) => void; readonly activateCore?: (context: CoreActivationContext) => void; readonly kernel: RuntimeKernel; } @@ -205,7 +212,25 @@ class RuntimeOwner implements Runtime { let published: PublishedQueue | undefined; this.installPromise = this.registry .install({ - activateCore: this.options.activateCore ?? (() => undefined), + activateCore: (context) => { + if (!this.ownsRegistrationHandshake()) { + throw new Error('Runtime owner generation changed'); + } + const ownerContext: RuntimeOwnerActivationContext = Object.freeze({ + boot: this.kernelBoot as Readonly, + generation: this.generation, + onDispose: context.onDispose, + signal: context.signal, + }); + this.invokeSynchronousActivation(this.options.activateOwner, ownerContext); + if (!this.ownsRegistrationHandshake()) { + throw new Error('Runtime owner generation changed'); + } + this.invokeSynchronousActivation(this.options.activateCore, context); + if (!this.ownsRegistrationHandshake()) { + throw new Error('Runtime owner generation changed'); + } + }, publish: () => { if (!this.ownsRegistrationHandshake()) { throw new Error('Runtime owner generation changed'); @@ -297,6 +322,26 @@ class RuntimeOwner implements Runtime { const descriptor = Object.getOwnPropertyDescriptor(this.options.target, 'boot'); return descriptor && 'value' in descriptor ? descriptor.value : undefined; } + + private invokeSynchronousActivation( + activation: ((context: Context) => void) | undefined, + context: Context + ): void { + if (!activation) return; + const returned = activation(context) as unknown; + if ( + (typeof returned === 'object' || typeof returned === 'function') && + returned !== null && + typeof (returned as { then?: unknown }).then === 'function' + ) { + try { + void Promise.resolve(returned).catch(() => undefined); + } catch { + // Rejection observation cannot make an asynchronous activation valid. + } + throw new TypeError('Runtime activation must be synchronous'); + } + } } /** Create one dormant runtime owner; `start` performs the test-only claim. */ diff --git a/crates/trusted-server-js/lib/src/kernel/sessions.ts b/crates/trusted-server-js/lib/src/kernel/sessions.ts new file mode 100644 index 000000000..d73fd8b76 --- /dev/null +++ b/crates/trusted-server-js/lib/src/kernel/sessions.ts @@ -0,0 +1,616 @@ +import { DisposableStack, type DisposeCallback, type DisposalErrorHandler } from './disposable'; +import type { IdentityGenerationResult, NavigationIdentityIssuer } from './identity'; + +/** Factory that obtains one fresh eight-byte identity prefix per navigation. */ +export type NavigationIdentityIssuerFactory = + () => IdentityGenerationResult; + +/** Immutable interfaces injected by the composition root for the runtime lifetime. */ +export type RuntimeInterfaces = Readonly>; + +/** Options used to construct one document-lifetime runtime session. */ +export interface RuntimeSessionOptions { + readonly createIdentityIssuer: NavigationIdentityIssuerFactory; + readonly interfaces?: RuntimeInterfaces; + readonly onDisposalError?: DisposalErrorHandler; +} + +/** Result of creating the initial navigation or replacing the current navigation. */ +export type NavigationSessionResult = + | Readonly<{ ok: true; value: NavigationSession }> + | Readonly<{ + ok: false; + reason: + | 'identity_generation_failed' + | 'invalid_projection' + | 'navigation_already_started' + | 'navigation_transition_in_progress' + | 'runtime_disposed'; + }>; + +/** Result of creating one render attempt. */ +export type RenderAttemptResult = + | Readonly<{ ok: true; value: RenderAttemptScope }> + | Readonly<{ + ok: false; + reason: 'identity_generation_failed' | 'attempt_exists' | 'stale_owner'; + }>; + +/** Frozen runtime inventory intended only for ownership tests. */ +export interface RuntimeInventorySnapshot { + readonly activeDisposers: number; + readonly currentNavigationGeneration: object | undefined; + readonly disposed: boolean; + readonly disposedByKind: Readonly>; + readonly disposedNavigations: number; + readonly navigationCount: number; +} + +/** Frozen navigation inventory intended only for ownership tests. */ +export interface NavigationInventorySnapshot { + readonly activeDisposers: number; + readonly aliases: number; + readonly attempts: number; + readonly batches: number; + readonly disposed: boolean; + readonly disposedByKind: Readonly>; + readonly hasAuctionProjection: boolean; + readonly intents: number; + readonly retainedAttemptScopes: number; + readonly retainedBatchScopes: number; + readonly targetingOwners: number; +} + +/** A document-lifetime owner that atomically replaces route-local sessions. */ +export interface RuntimeSession { + readonly generation: object; + readonly interfaces: RuntimeInterfaces; + readonly disposed: boolean; + readonly currentNavigation: NavigationSession | undefined; + readonly startInitialNavigation: (projection?: Readonly) => NavigationSessionResult; + readonly replaceNavigation: () => NavigationSessionResult; + readonly isCurrent: () => boolean; + readonly onDispose: (kind: string, callback: DisposeCallback) => void; + readonly dispose: () => void; + readonly snapshotInventoryForTest: () => RuntimeInventorySnapshot; +} + +/** One route-local owner for aliases, intent, targeting, batches, attempts, and projection. */ +export interface NavigationSession { + readonly generation: object; + readonly disposed: boolean; + readonly currentAuctionProjection: Readonly | undefined; + readonly signal: AbortSignal; + readonly capture: ( + callback: (...arguments_: Arguments) => unknown + ) => (...arguments_: Arguments) => boolean; + readonly claimAlias: (alias: string) => boolean; + readonly claimIntent: (slot: string) => boolean; + readonly claimTargeting: (slot: string) => boolean; + readonly createAuctionBatch: (key: string) => AuctionBatchScope | undefined; + readonly installAuctionProjection: (projection: Readonly) => boolean; + readonly isCurrent: () => boolean; + readonly onDispose: (kind: string, callback: DisposeCallback) => void; + readonly dispose: () => void; + readonly snapshotInventoryForTest: () => NavigationInventorySnapshot; +} + +/** Navigation-owned scope for one shared auction request and its child attempts. */ +export interface AuctionBatchScope { + readonly generation: object; + readonly disposed: boolean; + readonly signal: AbortSignal; + readonly createRenderAttempt: (slot: string) => RenderAttemptResult; + readonly isCurrent: () => boolean; + readonly onDispose: (kind: string, callback: DisposeCallback) => void; + readonly dispose: () => void; +} + +/** Attempt-owned scope for timers, listeners, ports, and one terminal lifecycle. */ +export interface RenderAttemptScope { + readonly generation: object; + readonly id: string; + readonly slot: string; + readonly disposed: boolean; + readonly signal: AbortSignal; + readonly capture: ( + callback: (...arguments_: Arguments) => unknown + ) => (...arguments_: Arguments) => boolean; + readonly isCurrent: () => boolean; + readonly onDispose: (kind: string, callback: DisposeCallback) => void; + readonly dispose: () => void; +} + +const EMPTY_INTERFACES = Object.freeze({}); + +function frozenRecord(source: ReadonlyMap): Readonly> { + const output: Record = {}; + for (const [key, value] of source) output[key] = value; + return Object.freeze(output); +} + +function recursivelyFrozen(value: unknown, visited = new Set()): boolean { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return true; + if (visited.has(value)) return true; + visited.add(value); + try { + if (!Object.isFrozen(value)) return false; + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + !descriptor || + !('value' in descriptor) || + !recursivelyFrozen(descriptor.value, visited) + ) { + return false; + } + } + return true; + } catch { + return false; + } +} + +class OwnerScope { + public readonly generation = Object.freeze({}); + private readonly disposables: DisposableStack; + private readonly activeByKind = new Map(); + private readonly disposedByKind = new Map(); + + public constructor(onDisposalError?: DisposalErrorHandler) { + this.disposables = new DisposableStack(onDisposalError); + } + + public get disposed(): boolean { + return this.disposables.disposed; + } + + public get signal(): AbortSignal { + return this.disposables.signal; + } + + public onDispose(kind: string, callback: DisposeCallback): void { + if (typeof kind !== 'string' || kind.length === 0) + throw new TypeError('Disposer kind required'); + if (typeof callback !== 'function') throw new TypeError('A disposer must be a function'); + this.activeByKind.set(kind, (this.activeByKind.get(kind) ?? 0) + 1); + this.disposables.onDispose(() => { + const remaining = (this.activeByKind.get(kind) ?? 1) - 1; + if (remaining === 0) this.activeByKind.delete(kind); + else this.activeByKind.set(kind, remaining); + this.disposedByKind.set(kind, (this.disposedByKind.get(kind) ?? 0) + 1); + callback(); + }); + } + + public dispose(): void { + this.disposables.dispose(); + } + + public activeDisposerCount(): number { + let total = 0; + for (const count of this.activeByKind.values()) total += count; + return total; + } + + public disposedInventory(): Readonly> { + return frozenRecord(this.disposedByKind); + } +} + +class RenderAttemptOwner implements RenderAttemptScope { + private readonly scope: OwnerScope; + + public constructor( + public readonly id: string, + public readonly slot: string, + private readonly ownerIsCurrent: () => boolean, + onDisposalError?: DisposalErrorHandler + ) { + this.scope = new OwnerScope(onDisposalError); + } + + public get generation(): object { + return this.scope.generation; + } + + public get disposed(): boolean { + return this.scope.disposed; + } + + public get signal(): AbortSignal { + return this.scope.signal; + } + + public capture( + callback: (...arguments_: Arguments) => unknown + ): (...arguments_: Arguments) => boolean { + return (...arguments_: Arguments): boolean => { + if (!this.isCurrent()) return false; + callback(...arguments_); + return true; + }; + } + + public isCurrent(): boolean { + return !this.disposed && this.ownerIsCurrent(); + } + + public onDispose(kind: string, callback: DisposeCallback): void { + this.scope.onDispose(kind, callback); + } + + public dispose(): void { + this.scope.dispose(); + } +} + +class AuctionBatchOwner implements AuctionBatchScope { + private readonly scope: OwnerScope; + private readonly attempts = new Map(); + private readonly attemptOrder: RenderAttemptOwner[] = []; + private isDisposing = false; + + public constructor( + private readonly issuer: NavigationIdentityIssuer, + private readonly ownerIsCurrent: () => boolean, + private readonly attemptExists: (slot: string) => boolean, + private readonly registerAttempt: (slot: string, attempt: RenderAttemptOwner) => boolean, + private readonly releaseAttempt: (slot: string, attempt: RenderAttemptOwner) => void, + private readonly onDisposalError?: DisposalErrorHandler + ) { + this.scope = new OwnerScope(onDisposalError); + } + + public get generation(): object { + return this.scope.generation; + } + + public get disposed(): boolean { + return this.isDisposing || this.scope.disposed; + } + + public get signal(): AbortSignal { + return this.scope.signal; + } + + public createRenderAttempt(slot: string): RenderAttemptResult { + if (!this.isCurrent()) return Object.freeze({ ok: false, reason: 'stale_owner' }); + if (this.attempts.has(slot) || this.attemptExists(slot)) { + return Object.freeze({ ok: false, reason: 'attempt_exists' }); + } + const identity = this.issuer.mintAttemptId(); + if (!identity.ok) return identity; + const attemptReference: { current?: RenderAttemptOwner } = {}; + const attempt = new RenderAttemptOwner( + identity.value, + slot, + (): boolean => this.isCurrent() && this.attempts.get(slot) === attemptReference.current, + this.onDisposalError + ); + attemptReference.current = attempt; + if (!this.registerAttempt(slot, attempt)) { + attempt.dispose(); + return Object.freeze({ ok: false, reason: 'stale_owner' }); + } + this.attempts.set(slot, attempt); + this.attemptOrder.push(attempt); + attempt.onDispose('attempt-index', () => { + this.attempts.delete(slot); + this.releaseAttempt(slot, attempt); + const orderIndex = this.attemptOrder.indexOf(attempt); + if (orderIndex >= 0) this.attemptOrder.splice(orderIndex, 1); + }); + return Object.freeze({ ok: true, value: attempt }); + } + + public isCurrent(): boolean { + return !this.disposed && this.ownerIsCurrent(); + } + + public retainedAttemptCount(): number { + return this.attemptOrder.length; + } + + public onDispose(kind: string, callback: DisposeCallback): void { + this.scope.onDispose(kind, callback); + } + + public dispose(): void { + if (this.disposed) return; + this.isDisposing = true; + for (let index = this.attemptOrder.length - 1; index >= 0; index -= 1) { + this.attemptOrder[index]?.dispose(); + } + this.attemptOrder.length = 0; + this.attempts.clear(); + this.scope.dispose(); + } +} + +class NavigationSessionOwner implements NavigationSession { + private readonly scope: OwnerScope; + private readonly aliases = new Set(); + private readonly intents = new Set(); + private readonly targetingOwners = new Set(); + private readonly batches = new Map(); + private readonly attempts = new Map(); + private readonly batchOrder: AuctionBatchOwner[] = []; + private projection: Readonly | undefined; + private isDisposing = false; + + public constructor( + private readonly issuer: NavigationIdentityIssuer, + initialProjection: Readonly | undefined, + private readonly ownerIsCurrent: () => boolean, + private readonly onDisposed: () => void, + private readonly onDisposalError?: DisposalErrorHandler + ) { + this.scope = new OwnerScope(onDisposalError); + this.projection = initialProjection; + } + + public get generation(): object { + return this.scope.generation; + } + + public get disposed(): boolean { + return this.isDisposing || this.scope.disposed; + } + + public get signal(): AbortSignal { + return this.scope.signal; + } + + public get currentAuctionProjection(): Readonly | undefined { + return this.projection; + } + + public capture( + callback: (...arguments_: Arguments) => unknown + ): (...arguments_: Arguments) => boolean { + return (...arguments_: Arguments): boolean => { + if (!this.isCurrent()) return false; + callback(...arguments_); + return true; + }; + } + + public claimAlias(alias: string): boolean { + return this.claim(this.aliases, alias); + } + + public claimIntent(slot: string): boolean { + return this.claim(this.intents, slot); + } + + public claimTargeting(slot: string): boolean { + return this.claim(this.targetingOwners, slot); + } + + public createAuctionBatch(key: string): AuctionBatchScope | undefined { + if (!this.isCurrent() || this.batches.has(key)) return undefined; + const batchReference: { current?: AuctionBatchOwner } = {}; + const batch = new AuctionBatchOwner( + this.issuer, + (): boolean => this.isCurrent() && this.batches.get(key) === batchReference.current, + (slot) => this.attempts.has(slot), + (slot, attempt) => { + if (!this.isCurrent() || this.attempts.has(slot)) return false; + this.attempts.set(slot, attempt); + return true; + }, + (slot, attempt) => { + if (this.attempts.get(slot) === attempt) this.attempts.delete(slot); + }, + this.onDisposalError + ); + batchReference.current = batch; + this.batches.set(key, batch); + this.batchOrder.push(batch); + batch.onDispose('batch-index', () => { + this.batches.delete(key); + const orderIndex = this.batchOrder.indexOf(batch); + if (orderIndex >= 0) this.batchOrder.splice(orderIndex, 1); + }); + return batch; + } + + public installAuctionProjection(projection: Readonly): boolean { + if (!this.isCurrent() || this.projection !== undefined || !recursivelyFrozen(projection)) { + return false; + } + this.projection = projection; + return true; + } + + public isCurrent(): boolean { + return !this.disposed && this.ownerIsCurrent(); + } + + public onDispose(kind: string, callback: DisposeCallback): void { + this.scope.onDispose(kind, callback); + } + + public dispose(): void { + if (this.disposed) return; + this.isDisposing = true; + for (let index = this.batchOrder.length - 1; index >= 0; index -= 1) { + this.batchOrder[index]?.dispose(); + } + this.batchOrder.length = 0; + this.batches.clear(); + this.attempts.clear(); + this.scope.dispose(); + this.aliases.clear(); + this.intents.clear(); + this.targetingOwners.clear(); + this.projection = undefined; + this.onDisposed(); + } + + public snapshotInventoryForTest(): NavigationInventorySnapshot { + let retainedAttemptScopes = 0; + for (const batch of this.batchOrder) { + retainedAttemptScopes += batch.retainedAttemptCount(); + } + return Object.freeze({ + activeDisposers: this.scope.activeDisposerCount(), + aliases: this.aliases.size, + attempts: this.attempts.size, + batches: this.batches.size, + disposed: this.disposed, + disposedByKind: this.scope.disposedInventory(), + hasAuctionProjection: this.projection !== undefined, + intents: this.intents.size, + retainedAttemptScopes, + retainedBatchScopes: this.batchOrder.length, + targetingOwners: this.targetingOwners.size, + }); + } + + private claim(index: Set, key: string): boolean { + if (!this.isCurrent() || index.has(key)) return false; + index.add(key); + return true; + } +} + +class RuntimeSessionOwner implements RuntimeSession { + public readonly generation = Object.freeze({}); + public readonly interfaces: RuntimeInterfaces; + private readonly scope: OwnerScope; + private readonly createIdentityIssuer: NavigationIdentityIssuerFactory; + private readonly onDisposalError: DisposalErrorHandler | undefined; + private navigation: NavigationSessionOwner | undefined; + private started = false; + private disposedNavigations = 0; + private isDisposing = false; + private navigationTransitionInProgress = false; + + public constructor(options: RuntimeSessionOptions) { + this.createIdentityIssuer = options.createIdentityIssuer; + this.onDisposalError = options.onDisposalError; + this.scope = new OwnerScope(options.onDisposalError); + this.interfaces = options.interfaces ?? EMPTY_INTERFACES; + } + + public get disposed(): boolean { + return this.isDisposing || this.scope.disposed; + } + + public get currentNavigation(): NavigationSession | undefined { + return this.navigation; + } + + public startInitialNavigation(projection?: Readonly): NavigationSessionResult { + if (this.disposed) return Object.freeze({ ok: false, reason: 'runtime_disposed' }); + if (this.started) return Object.freeze({ ok: false, reason: 'navigation_already_started' }); + if (this.navigationTransitionInProgress) { + return Object.freeze({ ok: false, reason: 'navigation_transition_in_progress' }); + } + if (projection !== undefined && !recursivelyFrozen(projection)) { + return Object.freeze({ ok: false, reason: 'invalid_projection' }); + } + this.navigationTransitionInProgress = true; + try { + const result = this.createNavigation(projection); + if (result.ok) this.started = true; + return result; + } finally { + this.navigationTransitionInProgress = false; + } + } + + public replaceNavigation(): NavigationSessionResult { + if (this.disposed) return Object.freeze({ ok: false, reason: 'runtime_disposed' }); + if (this.navigationTransitionInProgress) { + return Object.freeze({ ok: false, reason: 'navigation_transition_in_progress' }); + } + this.navigationTransitionInProgress = true; + try { + const identity = this.obtainIdentityIssuer(); + if (!identity.ok) return identity; + if (this.disposed) return Object.freeze({ ok: false, reason: 'runtime_disposed' }); + + const previous = this.navigation; + this.navigation = undefined; + previous?.dispose(); + if (this.disposed) return Object.freeze({ ok: false, reason: 'runtime_disposed' }); + + const next = this.buildNavigation(identity.value, undefined); + this.navigation = next; + this.started = true; + return Object.freeze({ ok: true, value: next }); + } finally { + this.navigationTransitionInProgress = false; + } + } + + public isCurrent(): boolean { + return !this.disposed; + } + + public onDispose(kind: string, callback: DisposeCallback): void { + this.scope.onDispose(kind, callback); + } + + public dispose(): void { + if (this.disposed) return; + this.isDisposing = true; + this.navigation?.dispose(); + this.navigation = undefined; + this.scope.dispose(); + } + + public snapshotInventoryForTest(): RuntimeInventorySnapshot { + return Object.freeze({ + activeDisposers: this.scope.activeDisposerCount(), + currentNavigationGeneration: this.navigation?.generation, + disposed: this.disposed, + disposedByKind: this.scope.disposedInventory(), + disposedNavigations: this.disposedNavigations, + navigationCount: this.navigation && !this.navigation.disposed ? 1 : 0, + }); + } + + private createNavigation(projection?: Readonly): NavigationSessionResult { + const identity = this.obtainIdentityIssuer(); + if (!identity.ok) return identity; + if (this.disposed) return Object.freeze({ ok: false, reason: 'runtime_disposed' }); + const navigation = this.buildNavigation(identity.value, projection); + this.navigation = navigation; + return Object.freeze({ ok: true, value: navigation }); + } + + private buildNavigation( + identityIssuer: NavigationIdentityIssuer, + projection: Readonly | undefined + ): NavigationSessionOwner { + const navigationReference: { current?: NavigationSessionOwner } = {}; + const navigation = new NavigationSessionOwner( + identityIssuer, + projection, + () => !this.disposed && this.navigation === navigationReference.current, + () => { + this.disposedNavigations += 1; + }, + this.onDisposalError + ); + navigationReference.current = navigation; + return navigation; + } + + private obtainIdentityIssuer(): IdentityGenerationResult { + try { + return this.createIdentityIssuer(); + } catch { + return Object.freeze({ ok: false, reason: 'identity_generation_failed' }); + } + } +} + +/** Construct one document-lifetime runtime session from injected interfaces only. */ +export function createRuntimeSession(options: RuntimeSessionOptions): RuntimeSession { + return new RuntimeSessionOwner(options); +} diff --git a/crates/trusted-server-js/lib/src/services/context.ts b/crates/trusted-server-js/lib/src/services/context.ts new file mode 100644 index 000000000..bc80c5a62 --- /dev/null +++ b/crates/trusted-server-js/lib/src/services/context.ts @@ -0,0 +1,279 @@ +import type { DisposeCallback } from '../kernel/disposable'; + +const MAX_MANIFEST_INTEGRATIONS = 16; +const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +/** Owner boundary required for generation-scoped contributor registration. */ +export interface ContextContributorOwner { + readonly generation: object; + readonly isCurrent: () => boolean; + readonly onDispose: (kind: string, callback: DisposeCallback) => void; +} + +/** One integration's document-scoped contribution to an auction request. */ +export type AuctionContextContributor = () => Readonly> | undefined; + +/** Sanitized observation emitted when one contributor cannot be copied. */ +export interface AuctionContextContributorFailure { + readonly integrationId: string; + readonly reason: 'contributor_failed'; +} + +/** Runtime-owned contributor registry options. */ +export interface AuctionContextRegistryOptions { + readonly manifestIntegrationIds: readonly string[]; + readonly runtimeOwner: ContextContributorOwner; + readonly onContributorFailure?: (failure: AuctionContextContributorFailure) => void; +} + +/** Frozen test-only inventory for the runtime-owned registry. */ +export interface AuctionContextRegistryInventory { + readonly disposed: boolean; + readonly registrations: readonly string[]; +} + +/** Runtime-owned registry that snapshots contributors in manifest order. */ +export interface AuctionContextRegistry { + readonly register: ( + integrationId: string, + contributor: AuctionContextContributor, + owner: ContextContributorOwner + ) => boolean; + readonly snapshot: () => Readonly>; + readonly dispose: () => void; + readonly snapshotInventoryForTest: () => AuctionContextRegistryInventory; +} + +interface ContributorRecord { + readonly contributor: AuctionContextContributor; + readonly owner: ContextContributorOwner; + readonly ownerGeneration: object; +} + +const INVALID = Symbol('invalid_context_value'); + +function snapshotManifest(candidate: readonly string[]): readonly string[] { + if (!Object.isFrozen(candidate) || candidate.length > MAX_MANIFEST_INTEGRATIONS) { + throw new TypeError('Auction context manifest must be frozen and bounded'); + } + const seen = new Set(); + const snapshot: string[] = []; + for (const id of candidate) { + if (typeof id !== 'string' || !INTEGRATION_ID.test(id) || seen.has(id)) { + throw new TypeError('Auction context manifest contains an invalid integration id'); + } + seen.add(id); + snapshot.push(id); + } + return Object.freeze(snapshot); +} + +function cloneContextValue(value: unknown, ancestors: Set): unknown | typeof INVALID { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return value; + } + if (typeof value !== 'object') return INVALID; + if (ancestors.has(value)) return INVALID; + + try { + const prototype = Object.getPrototypeOf(value) as unknown; + const array = Array.isArray(value); + if ((array && prototype !== Array.prototype) || (!array && prototype !== Object.prototype)) { + return INVALID; + } + if (Object.getOwnPropertySymbols(value).length > 0) return INVALID; + ancestors.add(value); + if (array) { + const names = Object.getOwnPropertyNames(value); + if (names.length !== value.length + 1 || !names.includes('length')) return INVALID; + const output: 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 INVALID; + const cloned = cloneContextValue(descriptor.value, ancestors); + if (cloned === INVALID) return INVALID; + output.push(cloned); + } + return Object.freeze(output); + } + + const output: Record = {}; + for (const key of Object.getOwnPropertyNames(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return INVALID; + const cloned = cloneContextValue(descriptor.value, ancestors); + if (cloned === INVALID) return INVALID; + Object.defineProperty(output, key, { + configurable: true, + enumerable: true, + value: cloned, + writable: true, + }); + } + return Object.freeze(output); + } catch { + return INVALID; + } finally { + ancestors.delete(value); + } +} + +function copyContribution( + value: Readonly> +): Readonly> | undefined { + const cloned = cloneContextValue(value, new Set()); + return cloned === INVALID || + typeof cloned !== 'object' || + cloned === null || + Array.isArray(cloned) + ? undefined + : (cloned as Readonly>); +} + +class AuctionContextRegistryOwner implements AuctionContextRegistry { + private readonly manifestIntegrationIds: readonly string[]; + private readonly manifestSet: ReadonlySet; + private readonly runtimeGeneration: object; + private readonly registrations = new Map(); + private readonly runtimeOwner: ContextContributorOwner; + private readonly onContributorFailure: + ((failure: AuctionContextContributorFailure) => void) | undefined; + private isDisposed = false; + + public constructor(options: AuctionContextRegistryOptions) { + this.manifestIntegrationIds = snapshotManifest(options.manifestIntegrationIds); + this.manifestSet = new Set(this.manifestIntegrationIds); + this.runtimeOwner = options.runtimeOwner; + this.runtimeGeneration = options.runtimeOwner.generation; + this.onContributorFailure = options.onContributorFailure; + try { + options.runtimeOwner.onDispose('auction-context-registry', () => this.dispose()); + } catch { + this.dispose(); + } + if (!this.runtimeIsCurrent()) this.dispose(); + } + + public register( + integrationId: string, + contributor: AuctionContextContributor, + owner: ContextContributorOwner + ): boolean { + if ( + !this.runtimeIsCurrent() || + !this.manifestSet.has(integrationId) || + this.registrations.has(integrationId) || + typeof contributor !== 'function' || + !this.ownerIsCurrent(owner, owner.generation) + ) { + return false; + } + const record: ContributorRecord = Object.freeze({ + contributor, + owner, + ownerGeneration: owner.generation, + }); + this.registrations.set(integrationId, record); + try { + owner.onDispose('auction-context-contributor', () => { + if (this.registrations.get(integrationId) === record) { + this.registrations.delete(integrationId); + } + }); + } catch { + this.registrations.delete(integrationId); + return false; + } + if (!this.runtimeIsCurrent() || !this.ownerIsCurrent(owner, record.ownerGeneration)) { + this.registrations.delete(integrationId); + return false; + } + return true; + } + + public snapshot(): Readonly> { + if (!this.runtimeIsCurrent()) { + this.dispose(); + return Object.freeze({}); + } + const output: Record = {}; + for (const integrationId of this.manifestIntegrationIds) { + const record = this.registrations.get(integrationId); + if (!record || !this.ownerIsCurrent(record.owner, record.ownerGeneration)) continue; + let contribution: Readonly> | undefined; + try { + const candidate = record.contributor(); + if (candidate === undefined) continue; + contribution = copyContribution(candidate); + } catch { + contribution = undefined; + } + if (!contribution) { + this.reportFailure(integrationId); + continue; + } + if (!this.runtimeIsCurrent()) return Object.freeze({}); + if (!this.ownerIsCurrent(record.owner, record.ownerGeneration)) continue; + for (const [key, value] of Object.entries(contribution)) { + Object.defineProperty(output, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); + } + } + return this.runtimeIsCurrent() ? Object.freeze(output) : Object.freeze({}); + } + + public dispose(): void { + if (this.isDisposed) return; + this.isDisposed = true; + this.registrations.clear(); + } + + public snapshotInventoryForTest(): AuctionContextRegistryInventory { + return Object.freeze({ + disposed: this.isDisposed, + registrations: Object.freeze( + this.manifestIntegrationIds.filter((id) => this.registrations.has(id)) + ), + }); + } + + private runtimeIsCurrent(): boolean { + return ( + !this.isDisposed && + this.runtimeOwner.generation === this.runtimeGeneration && + this.ownerIsCurrent(this.runtimeOwner, this.runtimeGeneration) + ); + } + + private ownerIsCurrent(owner: ContextContributorOwner, generation: object): boolean { + try { + return owner.generation === generation && owner.isCurrent(); + } catch { + return false; + } + } + + private reportFailure(integrationId: string): void { + try { + this.onContributorFailure?.(Object.freeze({ integrationId, reason: 'contributor_failed' })); + } catch { + // Observational reporting cannot affect the remaining manifest contributors. + } + } +} + +/** Construct one manifest-bounded, runtime-owned context contributor registry. */ +export function createAuctionContextRegistry( + options: AuctionContextRegistryOptions +): AuctionContextRegistry { + return new AuctionContextRegistryOwner(options); +} diff --git a/crates/trusted-server-js/lib/src/services/projections.ts b/crates/trusted-server-js/lib/src/services/projections.ts new file mode 100644 index 000000000..926ef2259 --- /dev/null +++ b/crates/trusted-server-js/lib/src/services/projections.ts @@ -0,0 +1,189 @@ +import type { NavigationSession } from '../kernel/sessions'; + +/** Shared maximum across server-projected and programmatically admitted slots. */ +export const MAX_ACTIVE_SLOT_RECORDS = 256; + +/** Exact projection parser injected by the composition root. */ +export type AuctionProjectionParser = (candidate: unknown) => object | undefined; + +/** A reversible reservation prepared without mutating the live slot registry. */ +export interface PreparedProjectionSlots { + readonly ownerGeneration: object; + readonly commit: () => boolean; + readonly rollback: () => void; +} + +/** Slot-registry transaction boundary consumed by the page-bids controller. */ +export interface ProjectionSlotRegistry { + readonly prepareProjectionSlots: ( + ownerGeneration: object, + slots: readonly string[], + maximumActiveSlots: number + ) => PreparedProjectionSlots | undefined; +} + +/** Result of one current-generation page-bids response. */ +export type PageBidsCommitResult = + | Readonly<{ status: 'committed' }> + | Readonly<{ + status: 'rejected'; + reason: 'capacity' | 'duplicate' | 'malformed' | 'stale'; + }>; + +/** One-response controller bound to a single navigation generation. */ +export interface PageBidsController { + readonly commit: (candidate: unknown) => PageBidsCommitResult; +} + +/** Dependencies for one navigation-bound page-bids controller. */ +export interface PageBidsControllerOptions { + readonly navigation: NavigationSession; + readonly parseProjection: AuctionProjectionParser; + readonly slotRegistry: ProjectionSlotRegistry; +} + +const COMMITTED = Object.freeze({ status: 'committed' as const }); +const rejected = { + capacity: Object.freeze({ status: 'rejected' as const, reason: 'capacity' as const }), + duplicate: Object.freeze({ status: 'rejected' as const, reason: 'duplicate' as const }), + malformed: Object.freeze({ status: 'rejected' as const, reason: 'malformed' as const }), + stale: Object.freeze({ status: 'rejected' as const, reason: 'stale' as const }), +}; + +function recursivelyFreeze(value: unknown, visited = new Set()): boolean { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return true; + if (visited.has(value)) return true; + visited.add(value); + try { + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + !descriptor || + !('value' in descriptor) || + !recursivelyFreeze(descriptor.value, visited) + ) { + return false; + } + } + Object.freeze(value); + return true; + } catch { + return false; + } +} + +function projectedSlots(projection: object): readonly string[] | undefined { + try { + const auctionDescriptor = Object.getOwnPropertyDescriptor(projection, 'auction'); + if (!auctionDescriptor || !('value' in auctionDescriptor)) return undefined; + const auction = auctionDescriptor.value; + if (typeof auction !== 'object' || auction === null) return undefined; + const resultsDescriptor = Object.getOwnPropertyDescriptor(auction, 'results'); + if (!resultsDescriptor || !('value' in resultsDescriptor)) return undefined; + const results = resultsDescriptor.value; + if (!Array.isArray(results) || results.length > MAX_ACTIVE_SLOT_RECORDS) return undefined; + const slots: string[] = []; + const seen = new Set(); + for (const result of results) { + if (typeof result !== 'object' || result === null) return undefined; + const slotDescriptor = Object.getOwnPropertyDescriptor(result, 'slot'); + if ( + !slotDescriptor || + !('value' in slotDescriptor) || + typeof slotDescriptor.value !== 'string' + ) { + return undefined; + } + if (seen.has(slotDescriptor.value)) return undefined; + seen.add(slotDescriptor.value); + slots.push(slotDescriptor.value); + } + return Object.freeze(slots); + } catch { + return undefined; + } +} + +function rollback(reservation: PreparedProjectionSlots): void { + try { + reservation.rollback(); + } catch { + // Rollback is best-effort; a conforming slot transaction is reversibly prepared. + } +} + +/** Parse, deep-copy through the injected parser, and recursively freeze initial boot input. */ +export function prepareInitialAuctionProjection( + candidate: unknown, + parseProjection: AuctionProjectionParser +): Readonly | undefined { + try { + const parsed = parseProjection(candidate); + if (!parsed || !recursivelyFreeze(parsed)) return undefined; + return parsed; + } catch { + return undefined; + } +} + +/** Construct one transactional page-bids controller for a navigation generation. */ +export function createPageBidsController(options: PageBidsControllerOptions): PageBidsController { + const ownerGeneration = options.navigation.generation; + let didCommit = options.navigation.currentAuctionProjection !== undefined; + + return Object.freeze({ + commit(candidate: unknown): PageBidsCommitResult { + if (!options.navigation.isCurrent() || options.navigation.generation !== ownerGeneration) { + return rejected.stale; + } + if (didCommit || options.navigation.currentAuctionProjection !== undefined) { + return rejected.duplicate; + } + + const projection = prepareInitialAuctionProjection(candidate, options.parseProjection); + if (!projection) return rejected.malformed; + const slots = projectedSlots(projection); + if (!slots) return rejected.malformed; + if (!options.navigation.isCurrent()) return rejected.stale; + + let reservation: PreparedProjectionSlots | undefined; + try { + reservation = options.slotRegistry.prepareProjectionSlots( + ownerGeneration, + slots, + MAX_ACTIVE_SLOT_RECORDS + ); + } catch { + return rejected.capacity; + } + if (!reservation || reservation.ownerGeneration !== ownerGeneration) { + if (reservation) rollback(reservation); + return rejected.capacity; + } + if (!options.navigation.isCurrent()) { + rollback(reservation); + return rejected.stale; + } + + try { + if (!reservation.commit()) { + rollback(reservation); + return rejected.capacity; + } + } catch { + rollback(reservation); + return rejected.capacity; + } + if (!options.navigation.isCurrent()) { + rollback(reservation); + return rejected.stale; + } + if (!options.navigation.installAuctionProjection(projection)) { + rollback(reservation); + return options.navigation.isCurrent() ? rejected.duplicate : rejected.stale; + } + didCommit = true; + return COMMITTED; + }, + }); +} diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 25f12e7fb..591adcc0e 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -8,6 +8,7 @@ import { createNoopBrowserComposition, createTestBrowserRuntimeComposition, } from '../../src/composition/browser'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; function createTarget() { return { @@ -165,6 +166,272 @@ describe('browser composition', () => { expect(Object.isFrozen(composition.runtime)).toBe(true); }); + 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' }], + }, + bids: [], + }; + let prefix = 0; + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId: 'a'.repeat(64), + manifest: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, + 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: { bindingStatus: () => 'pending' }, + prebid: { bindingStatus: () => 'pending' }, + messaging: { installCaptureListener: () => vi.fn() }, + }, + coreActivations: { + bridgeRecognizer: vi.fn(), + 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); + expect(session?.currentNavigation?.currentAuctionProjection).toEqual(projection); + expect(Object.isFrozen(session?.currentNavigation?.currentAuctionProjection)).toBe(true); + + 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(replacement.value.currentAuctionProjection).toBeUndefined(); + expect(composition.runtimeSessionForTest()).toBe(session); + + const pageBids = composition.pageBidsControllerForTest(); + expect( + pageBids?.commit({ + version: 1, + auction: { + version: 1, + auctionId: 'spa', + results: [{ slot: 'spa-slot', outcome: 'no_bid' }], + }, + bids: [], + }) + ).toEqual({ status: 'committed' }); + expect(composition.projectionSlotsForTest()).toEqual(['spa-slot']); + + composition.runtime.dispose(); + expect(session?.disposed).toBe(true); + }); + + it('unwinds a lazily-created session when navigation identity generation fails', async () => { + const bridge = vi.fn(); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId: 'a'.repeat(64), + manifest: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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: { + bridgeRecognizer: bridge, + 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(bridge).not.toHaveBeenCalled(); + }); + + 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: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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: { + bridgeRecognizer: vi.fn(), + 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' }], + }, + 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: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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: { + bridgeRecognizer: vi.fn(), + 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('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: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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: { + bridgeRecognizer: vi.fn(), + correctnessGptListeners: vi.fn(), + }, + } + ); + + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + programmaticSlots[0] = 'publisher-mutated'; + programmaticSlots.length = 1; + + expect(composition.runtimeSessionForTest()?.replaceNavigation()).toMatchObject({ ok: true }); + expect(composition.projectionSlotsForTest()).toEqual([]); + }); + it('constructs or activates nothing after a terminal fallback', async () => { vi.useFakeTimers(); const serviceConstruction = vi.fn(() => ({ @@ -220,6 +487,8 @@ describe('browser composition', () => { state: 'fallback', reason: 'bundle_partial', }); + expect(composition.runtimeSessionForTest()).toBeUndefined(); + expect(composition.projectionSlotsForTest()).toBeUndefined(); expect(vi.getTimerCount()).toBe(0); expect( 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..18db61ef9 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/identity.test.ts @@ -0,0 +1,170 @@ +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('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/sessions.test.ts b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts new file mode 100644 index 000000000..f64abec5e --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts @@ -0,0 +1,332 @@ +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('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('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}$/); + 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('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/services/context.test.ts b/crates/trusted-server-js/lib/test/services/context.test.ts new file mode 100644 index 000000000..57cb48eec --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/context.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createAuctionContextRegistry, + type ContextContributorOwner, +} from '../../src/services/context'; + +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('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('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('fails closed for a manifest beyond the integration bound', () => { + const ids = Object.freeze(Array.from({ length: 17 }, (_, index) => `integration-${index}`)); + + expect(() => + createAuctionContextRegistry({ manifestIntegrationIds: ids, runtimeOwner: owner() }) + ).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..818583c0f --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/projections.test.ts @@ -0,0 +1,285 @@ +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 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 })), + }, + 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 string[], + maximumActiveSlots: number + ): PreparedProjectionSlots | undefined { + this.prepareCalls += 1; + if ( + this.slots.size + slots.length > maximumActiveSlots || + slots.some((slot) => this.slots.has(slot)) + ) { + return undefined; + } + let committed = false; + return Object.freeze({ + ownerGeneration, + commit: () => { + this.commitHook?.(); + for (const slot of slots) this.slots.add(slot); + committed = true; + return true; + }, + rollback: () => { + if (!committed) return; + for (const slot of slots) this.slots.delete(slot); + 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('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(); + }); +}); From fa08031c8be926dafb1448db9d348dfcc229424c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:20:03 -0700 Subject: [PATCH 026/194] Harden Task 9 ownership boundaries --- .../lib/src/kernel/identity.ts | 52 +- .../lib/src/kernel/runtime.ts | 15 +- .../lib/src/kernel/sessions.ts | 73 +- .../lib/src/services/context.ts | 459 ++++++++++-- .../lib/test/kernel/identity.test.ts | 92 +++ .../lib/test/kernel/runtime.test.ts | 45 ++ .../lib/test/kernel/sessions.test.ts | 57 ++ .../lib/test/services/context.test.ts | 693 ++++++++++++++++++ 8 files changed, 1374 insertions(+), 112 deletions(-) diff --git a/crates/trusted-server-js/lib/src/kernel/identity.ts b/crates/trusted-server-js/lib/src/kernel/identity.ts index a366dc485..96f6a8c35 100644 --- a/crates/trusted-server-js/lib/src/kernel/identity.ts +++ b/crates/trusted-server-js/lib/src/kernel/identity.ts @@ -86,7 +86,13 @@ function createNavigationIdentityIssuer( } const prefixResult = randomBytes(8, source, observer); if (!prefixResult.ok) return prefixResult; - const prefix = prefixResult.value; + let prefix: Uint8Array; + try { + prefix = new Uint8Array(new ArrayBuffer(8)); + prefix.set(prefixResult.value); + } catch { + return reportFailure(observer); + } let highWord = initialOrdinal[0] >>> 0; let lowWord = initialOrdinal[1] >>> 0; @@ -95,19 +101,39 @@ function createNavigationIdentityIssuer( if (highWord === 0xffff_ffff && lowWord === 0xffff_ffff) { return reportFailure(observer); } - if (lowWord === 0xffff_ffff) { - highWord = (highWord + 1) >>> 0; - lowWord = 0; - } else { - lowWord = (lowWord + 1) >>> 0; + try { + const nextHighWord = lowWord === 0xffff_ffff ? (highWord + 1) >>> 0 : highWord; + const nextLowWord = lowWord === 0xffff_ffff ? 0 : (lowWord + 1) >>> 0; + const identity = new Uint8Array(16); + identity.set(prefix, 0); + const view = new DataView(identity.buffer); + view.setUint32(8, nextHighWord, false); + view.setUint32(12, nextLowWord, false); + if (identity.byteLength !== 16) return reportFailure(observer); + for (let index = 0; index < prefix.length; index += 1) { + if (identity[index] !== prefix[index]) return reportFailure(observer); + } + if ( + identity[8] !== nextHighWord >>> 24 || + identity[9] !== ((nextHighWord >>> 16) & 0xff) || + identity[10] !== ((nextHighWord >>> 8) & 0xff) || + identity[11] !== (nextHighWord & 0xff) || + identity[12] !== nextLowWord >>> 24 || + identity[13] !== ((nextLowWord >>> 16) & 0xff) || + identity[14] !== ((nextLowWord >>> 8) & 0xff) || + identity[15] !== (nextLowWord & 0xff) + ) { + return reportFailure(observer); + } + const encoded = encodeBase64Url(identity); + if (encoded.length !== 22) return reportFailure(observer); + const result = Object.freeze({ ok: true as const, value: `a1_${encoded}` }); + highWord = nextHighWord; + lowWord = nextLowWord; + return result; + } catch { + return reportFailure(observer); } - - const identity = new Uint8Array(16); - identity.set(prefix, 0); - const view = new DataView(identity.buffer); - view.setUint32(8, highWord, false); - view.setUint32(12, lowWord, false); - return Object.freeze({ ok: true, value: `a1_${encodeBase64Url(identity)}` }); }, snapshotOrdinalForTest: () => Object.freeze([highWord, lowWord] as const), }); diff --git a/crates/trusted-server-js/lib/src/kernel/runtime.ts b/crates/trusted-server-js/lib/src/kernel/runtime.ts index 4f5bbc8c4..fd9303ffd 100644 --- a/crates/trusted-server-js/lib/src/kernel/runtime.ts +++ b/crates/trusted-server-js/lib/src/kernel/runtime.ts @@ -213,7 +213,7 @@ class RuntimeOwner implements Runtime { this.installPromise = this.registry .install({ activateCore: (context) => { - if (!this.ownsRegistrationHandshake()) { + if (!this.ownsInstallingActivation(context)) { throw new Error('Runtime owner generation changed'); } const ownerContext: RuntimeOwnerActivationContext = Object.freeze({ @@ -223,11 +223,11 @@ class RuntimeOwner implements Runtime { signal: context.signal, }); this.invokeSynchronousActivation(this.options.activateOwner, ownerContext); - if (!this.ownsRegistrationHandshake()) { + if (!this.ownsInstallingActivation(context)) { throw new Error('Runtime owner generation changed'); } this.invokeSynchronousActivation(this.options.activateCore, context); - if (!this.ownsRegistrationHandshake()) { + if (!this.ownsInstallingActivation(context)) { throw new Error('Runtime owner generation changed'); } }, @@ -317,6 +317,15 @@ class RuntimeOwner implements Runtime { } } + private ownsInstallingActivation(context: CoreActivationContext): boolean { + return ( + this.runtimeState === 'installing' && + !context.signal.aborted && + this.registry?.state === 'activating' && + this.ownsRegistrationHandshake() + ); + } + private bootCandidate(): unknown { if (this.options.boot !== undefined) return this.options.boot; const descriptor = Object.getOwnPropertyDescriptor(this.options.target, 'boot'); diff --git a/crates/trusted-server-js/lib/src/kernel/sessions.ts b/crates/trusted-server-js/lib/src/kernel/sessions.ts index d73fd8b76..cdf81d0e5 100644 --- a/crates/trusted-server-js/lib/src/kernel/sessions.ts +++ b/crates/trusted-server-js/lib/src/kernel/sessions.ts @@ -336,17 +336,26 @@ class NavigationSessionOwner implements NavigationSession { private readonly batches = new Map(); private readonly attempts = new Map(); private readonly batchOrder: AuctionBatchOwner[] = []; + private issuer: NavigationIdentityIssuer | undefined; + private ownerIsCurrentCallback: (() => boolean) | undefined; + private onDisposingCallback: (() => DisposeCallback | undefined) | undefined; + private onDisposedCallback: (() => void) | undefined; private projection: Readonly | undefined; private isDisposing = false; public constructor( - private readonly issuer: NavigationIdentityIssuer, + issuer: NavigationIdentityIssuer, initialProjection: Readonly | undefined, - private readonly ownerIsCurrent: () => boolean, - private readonly onDisposed: () => void, + ownerIsCurrent: () => boolean, + onDisposing: () => DisposeCallback | undefined, + onDisposed: () => void, private readonly onDisposalError?: DisposalErrorHandler ) { this.scope = new OwnerScope(onDisposalError); + this.issuer = issuer; + this.ownerIsCurrentCallback = ownerIsCurrent; + this.onDisposingCallback = onDisposing; + this.onDisposedCallback = onDisposed; this.projection = initialProjection; } @@ -389,10 +398,11 @@ class NavigationSessionOwner implements NavigationSession { } public createAuctionBatch(key: string): AuctionBatchScope | undefined { - if (!this.isCurrent() || this.batches.has(key)) return undefined; + const issuer = this.issuer; + if (!issuer || !this.isCurrent() || this.batches.has(key)) return undefined; const batchReference: { current?: AuctionBatchOwner } = {}; const batch = new AuctionBatchOwner( - this.issuer, + issuer, (): boolean => this.isCurrent() && this.batches.get(key) === batchReference.current, (slot) => this.attempts.has(slot), (slot, attempt) => { @@ -425,7 +435,7 @@ class NavigationSessionOwner implements NavigationSession { } public isCurrent(): boolean { - return !this.disposed && this.ownerIsCurrent(); + return !this.disposed && (this.ownerIsCurrentCallback?.() ?? false); } public onDispose(kind: string, callback: DisposeCallback): void { @@ -435,18 +445,31 @@ class NavigationSessionOwner implements NavigationSession { public dispose(): void { if (this.disposed) return; this.isDisposing = true; - for (let index = this.batchOrder.length - 1; index >= 0; index -= 1) { - this.batchOrder[index]?.dispose(); + const releaseTransition = this.onDisposingCallback?.(); + try { + for (let index = this.batchOrder.length - 1; index >= 0; index -= 1) { + this.batchOrder[index]?.dispose(); + } + this.batchOrder.length = 0; + this.batches.clear(); + this.attempts.clear(); + this.scope.dispose(); + this.aliases.clear(); + this.intents.clear(); + this.targetingOwners.clear(); + this.projection = undefined; + } finally { + const onDisposed = this.onDisposedCallback; + this.issuer = undefined; + this.ownerIsCurrentCallback = undefined; + this.onDisposingCallback = undefined; + this.onDisposedCallback = undefined; + try { + onDisposed?.(); + } finally { + releaseTransition?.(); + } } - this.batchOrder.length = 0; - this.batches.clear(); - this.attempts.clear(); - this.scope.dispose(); - this.aliases.clear(); - this.intents.clear(); - this.targetingOwners.clear(); - this.projection = undefined; - this.onDisposed(); } public snapshotInventoryForTest(): NavigationInventorySnapshot { @@ -593,6 +616,22 @@ class RuntimeSessionOwner implements RuntimeSession { projection, () => !this.disposed && this.navigation === navigationReference.current, () => { + const disposingNavigation = navigationReference.current; + const ownsCurrent = + disposingNavigation !== undefined && this.navigation === disposingNavigation; + if (ownsCurrent) this.navigation = undefined; + if (!ownsCurrent || this.navigationTransitionInProgress || this.disposed) return undefined; + this.navigationTransitionInProgress = true; + return () => { + this.navigationTransitionInProgress = false; + }; + }, + () => { + const disposedNavigation = navigationReference.current; + if (disposedNavigation && this.navigation === disposedNavigation) { + this.navigation = undefined; + } + delete navigationReference.current; this.disposedNavigations += 1; }, this.onDisposalError diff --git a/crates/trusted-server-js/lib/src/services/context.ts b/crates/trusted-server-js/lib/src/services/context.ts index bc80c5a62..b16f7ebf2 100644 --- a/crates/trusted-server-js/lib/src/services/context.ts +++ b/crates/trusted-server-js/lib/src/services/context.ts @@ -2,6 +2,12 @@ import type { DisposeCallback } from '../kernel/disposable'; const MAX_MANIFEST_INTEGRATIONS = 16; const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; +// Context shares the existing /auction request-body ceiling. The structural +// bound follows from the smallest repeated JSON unit (`0,`), and the key bound +// reserves the seven bytes required by a one-property `{:null}` object. +const MAX_CONTEXT_JSON_BYTES = 256 * 1024; +const MAX_CONTEXT_STRUCTURE_ENTRIES = Math.floor((MAX_CONTEXT_JSON_BYTES - 1) / 2); +const MAX_CONTEXT_ENCODED_KEY_BYTES = MAX_CONTEXT_JSON_BYTES - 7; /** Owner boundary required for generation-scoped contributor registration. */ export interface ContextContributorOwner { @@ -50,7 +56,68 @@ interface ContributorRecord { readonly ownerGeneration: object; } -const INVALID = Symbol('invalid_context_value'); +type JsonPrimitive = null | boolean | number | string; + +interface ContextMeasurement { + readonly jsonBytes: number; + readonly keyBytes: number; + readonly structureEntries: number; +} + +interface PrimitiveSnapshot { + readonly kind: 'primitive'; + readonly measurement: ContextMeasurement; + readonly value: JsonPrimitive; +} + +interface ContextSnapshotEntry { + readonly encodedKeyBytes: number; + readonly key: string; + readonly sourceValue: unknown; + snapshot: ContextSnapshotNode | PrimitiveSnapshot | undefined; +} + +interface ContextSnapshotNode { + readonly array: boolean; + readonly entries: readonly ContextSnapshotEntry[]; + readonly kind: 'node'; + readonly source: object; +} + +interface ContextSnapshotGraph { + readonly nodes: readonly ContextSnapshotNode[]; + readonly root: ContextSnapshotNode; +} + +interface ClonedContextValue { + readonly measurement: ContextMeasurement; + readonly value: unknown; +} + +interface CopiedContributionEntry extends ContextMeasurement { + readonly key: string; + readonly value: unknown; +} + +interface CopiedContribution { + readonly entries: readonly CopiedContributionEntry[]; +} + +interface AcceptedContributionEntry { + readonly contribution: CopiedContributionEntry; + readonly integrationId: string; + readonly record: ContributorRecord; +} + +interface TraversalBudget { + keyBytes: number; + structureEntries: number; +} + +interface TraversalFrame { + readonly node: ContextSnapshotNode; + index: number; +} function snapshotManifest(candidate: readonly string[]): readonly string[] { if (!Object.isFrozen(candidate) || candidate.length > MAX_MANIFEST_INTEGRATIONS) { @@ -68,77 +135,225 @@ function snapshotManifest(candidate: readonly string[]): readonly string[] { return Object.freeze(snapshot); } -function cloneContextValue(value: unknown, ancestors: Set): unknown | typeof INVALID { - if ( - value === null || - typeof value === 'string' || - typeof value === 'boolean' || - (typeof value === 'number' && Number.isFinite(value)) - ) { - return value; +function boundedSum(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 = boundedSum(bytes, encodedBytes, maximum); + if (bytes > maximum) return bytes; + } + return bytes; +} + +function snapshotPrimitive(value: unknown): PrimitiveSnapshot | undefined { + let jsonBytes: number; + if (value === null) jsonBytes = 4; + else if (typeof value === 'boolean') jsonBytes = value ? 4 : 5; + else if (typeof value === 'number' && Number.isFinite(value)) jsonBytes = `${value}`.length; + else if (typeof value === 'string') { + jsonBytes = encodedJsonStringBytes(value, MAX_CONTEXT_JSON_BYTES); + if (jsonBytes > MAX_CONTEXT_JSON_BYTES) return undefined; + } else return undefined; + return Object.freeze({ + kind: 'primitive', + measurement: Object.freeze({ jsonBytes, keyBytes: 0, structureEntries: 0 }), + value, + }) as PrimitiveSnapshot; +} + +function spendStructure(budget: TraversalBudget): boolean { + budget.structureEntries += 1; + return budget.structureEntries <= MAX_CONTEXT_STRUCTURE_ENTRIES; +} + +function snapshotNode(source: object, budget: TraversalBudget): ContextSnapshotNode | undefined { + if (!spendStructure(budget)) return undefined; + const prototype = Object.getPrototypeOf(source) as unknown; + const array = Array.isArray(source); + if ((array && prototype !== Array.prototype) || (!array && prototype !== Object.prototype)) { + return undefined; + } + const entries: ContextSnapshotEntry[] = []; + if (array) { + const lengthDescriptor = Object.getOwnPropertyDescriptor(source, 'length'); + if ( + !lengthDescriptor || + !('value' in lengthDescriptor) || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 || + lengthDescriptor.value > MAX_CONTEXT_STRUCTURE_ENTRIES + ) { + return undefined; + } + for (let index = 0; index < lengthDescriptor.value; index += 1) { + if (!spendStructure(budget)) return undefined; + const key = `${index}`; + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + entries.push({ encodedKeyBytes: 0, key, snapshot: undefined, sourceValue: descriptor.value }); + } + } else { + for (const key in source as Record) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (!descriptor) continue; + if (!descriptor.enumerable || !('value' in descriptor) || !spendStructure(budget)) { + return undefined; + } + const encodedKeyBytes = encodedJsonStringBytes(key, MAX_CONTEXT_ENCODED_KEY_BYTES); + budget.keyBytes = boundedSum(budget.keyBytes, encodedKeyBytes, MAX_CONTEXT_ENCODED_KEY_BYTES); + if (budget.keyBytes > MAX_CONTEXT_ENCODED_KEY_BYTES) return undefined; + entries.push({ encodedKeyBytes, key, snapshot: undefined, sourceValue: descriptor.value }); + } } - if (typeof value !== 'object') return INVALID; - if (ancestors.has(value)) return INVALID; + return { array, entries, kind: 'node', source }; +} +function snapshotContextGraph(value: unknown): ContextSnapshotGraph | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const budget: TraversalBudget = { keyBytes: 0, structureEntries: 0 }; try { - const prototype = Object.getPrototypeOf(value) as unknown; - const array = Array.isArray(value); - if ((array && prototype !== Array.prototype) || (!array && prototype !== Object.prototype)) { - return INVALID; + const root = snapshotNode(value, budget); + if (!root || root.array) return undefined; + const active = new Set([value]); + const nodes: ContextSnapshotNode[] = [root]; + const stack: TraversalFrame[] = [{ index: 0, node: root }]; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (!frame) return undefined; + if (frame.index >= frame.node.entries.length) { + active.delete(frame.node.source); + stack.pop(); + continue; + } + const entry = frame.node.entries[frame.index]; + frame.index += 1; + if (!entry) return undefined; + const primitive = snapshotPrimitive(entry.sourceValue); + if (primitive) { + entry.snapshot = primitive; + continue; + } + if ( + typeof entry.sourceValue !== 'object' || + entry.sourceValue === null || + active.has(entry.sourceValue) + ) { + return undefined; + } + const child = snapshotNode(entry.sourceValue, budget); + if (!child) return undefined; + entry.snapshot = child; + nodes.push(child); + active.add(entry.sourceValue); + stack.push({ index: 0, node: child }); } - if (Object.getOwnPropertySymbols(value).length > 0) return INVALID; - ancestors.add(value); - if (array) { - const names = Object.getOwnPropertyNames(value); - if (names.length !== value.length + 1 || !names.includes('length')) return INVALID; - const output: 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 INVALID; - const cloned = cloneContextValue(descriptor.value, ancestors); - if (cloned === INVALID) return INVALID; - output.push(cloned); + return Object.freeze({ nodes: Object.freeze(nodes), root }); + } catch { + return undefined; + } +} + +function cloneSnapshotGraph(graph: ContextSnapshotGraph): CopiedContribution | undefined { + const completed = new Map(); + try { + for (let nodeIndex = graph.nodes.length - 1; nodeIndex >= 0; nodeIndex -= 1) { + const node = graph.nodes[nodeIndex]; + if (!node) return undefined; + const output: Record | unknown[] = node.array ? [] : {}; + let jsonBytes = 2; + let keyBytes = 0; + let structureEntries = 1; + for (let entryIndex = 0; entryIndex < node.entries.length; entryIndex += 1) { + const entry = node.entries[entryIndex]; + if (!entry?.snapshot) return undefined; + const child = + entry.snapshot.kind === 'node' ? completed.get(entry.snapshot) : entry.snapshot; + if (!child) return undefined; + const prefixBytes = + (entryIndex === 0 ? 0 : 1) + (node.array ? 0 : entry.encodedKeyBytes + 1); + jsonBytes = boundedSum(jsonBytes, prefixBytes, MAX_CONTEXT_JSON_BYTES); + jsonBytes = boundedSum(jsonBytes, child.measurement.jsonBytes, MAX_CONTEXT_JSON_BYTES); + keyBytes = boundedSum( + keyBytes, + entry.encodedKeyBytes + child.measurement.keyBytes, + MAX_CONTEXT_ENCODED_KEY_BYTES + ); + structureEntries = boundedSum( + structureEntries, + 1 + child.measurement.structureEntries, + MAX_CONTEXT_STRUCTURE_ENTRIES + ); + if ( + jsonBytes > MAX_CONTEXT_JSON_BYTES || + keyBytes > MAX_CONTEXT_ENCODED_KEY_BYTES || + structureEntries > MAX_CONTEXT_STRUCTURE_ENTRIES + ) { + return undefined; + } + Object.defineProperty(output, entry.key, { + configurable: true, + enumerable: true, + value: child.value, + writable: true, + }); } - return Object.freeze(output); + completed.set( + node, + Object.freeze({ + measurement: Object.freeze({ jsonBytes, keyBytes, structureEntries }), + value: Object.freeze(output), + }) + ); } - const output: Record = {}; - for (const key of Object.getOwnPropertyNames(value)) { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return INVALID; - const cloned = cloneContextValue(descriptor.value, ancestors); - if (cloned === INVALID) return INVALID; - Object.defineProperty(output, key, { - configurable: true, - enumerable: true, - value: cloned, - writable: true, + const entries = graph.root.entries.map((entry): CopiedContributionEntry | undefined => { + if (!entry.snapshot) return undefined; + const child = entry.snapshot.kind === 'node' ? completed.get(entry.snapshot) : entry.snapshot; + if (!child) return undefined; + return Object.freeze({ + jsonBytes: entry.encodedKeyBytes + 1 + child.measurement.jsonBytes, + key: entry.key, + keyBytes: entry.encodedKeyBytes + child.measurement.keyBytes, + structureEntries: 1 + child.measurement.structureEntries, + value: child.value, }); - } - return Object.freeze(output); + }); + return entries.some((entry) => entry === undefined) + ? undefined + : Object.freeze({ entries: Object.freeze(entries as CopiedContributionEntry[]) }); } catch { - return INVALID; - } finally { - ancestors.delete(value); + return undefined; } } -function copyContribution( - value: Readonly> -): Readonly> | undefined { - const cloned = cloneContextValue(value, new Set()); - return cloned === INVALID || - typeof cloned !== 'object' || - cloned === null || - Array.isArray(cloned) - ? undefined - : (cloned as Readonly>); +function copyContribution(value: unknown): CopiedContribution | undefined { + const graph = snapshotContextGraph(value); + return graph ? cloneSnapshotGraph(graph) : undefined; } class AuctionContextRegistryOwner implements AuctionContextRegistry { private readonly manifestIntegrationIds: readonly string[]; private readonly manifestSet: ReadonlySet; - private readonly runtimeGeneration: object; + private readonly runtimeGeneration: object | undefined; private readonly registrations = new Map(); private readonly runtimeOwner: ContextContributorOwner; private readonly onContributorFailure: @@ -149,8 +364,12 @@ class AuctionContextRegistryOwner implements AuctionContextRegistry { this.manifestIntegrationIds = snapshotManifest(options.manifestIntegrationIds); this.manifestSet = new Set(this.manifestIntegrationIds); this.runtimeOwner = options.runtimeOwner; - this.runtimeGeneration = options.runtimeOwner.generation; + this.runtimeGeneration = this.snapshotCurrentOwnerGeneration(options.runtimeOwner); this.onContributorFailure = options.onContributorFailure; + if (this.runtimeGeneration === undefined) { + this.dispose(); + return; + } try { options.runtimeOwner.onDispose('auction-context-registry', () => this.dispose()); } catch { @@ -164,33 +383,40 @@ class AuctionContextRegistryOwner implements AuctionContextRegistry { contributor: AuctionContextContributor, owner: ContextContributorOwner ): boolean { + const ownerGeneration = this.snapshotCurrentOwnerGeneration(owner); if ( !this.runtimeIsCurrent() || !this.manifestSet.has(integrationId) || this.registrations.has(integrationId) || typeof contributor !== 'function' || - !this.ownerIsCurrent(owner, owner.generation) + ownerGeneration === undefined ) { return false; } const record: ContributorRecord = Object.freeze({ contributor, owner, - ownerGeneration: owner.generation, + ownerGeneration, }); this.registrations.set(integrationId, record); try { owner.onDispose('auction-context-contributor', () => { - if (this.registrations.get(integrationId) === record) { - this.registrations.delete(integrationId); - } + this.deleteRegistration(integrationId, record); }); } catch { - this.registrations.delete(integrationId); + this.deleteRegistration(integrationId, record); return false; } - if (!this.runtimeIsCurrent() || !this.ownerIsCurrent(owner, record.ownerGeneration)) { - this.registrations.delete(integrationId); + const runtimeIsCurrent = this.runtimeIsCurrent(); + const recordSurvivedRuntimeReflection = this.registrations.get(integrationId) === record; + if (!runtimeIsCurrent || !recordSurvivedRuntimeReflection) { + this.deleteRegistration(integrationId, record); + return false; + } + const ownerIsCurrent = this.ownerIsCurrent(owner, ownerGeneration); + const recordSurvivedOwnerReflection = this.registrations.get(integrationId) === record; + if (!ownerIsCurrent || !recordSurvivedOwnerReflection) { + this.deleteRegistration(integrationId, record); return false; } return true; @@ -201,34 +427,95 @@ class AuctionContextRegistryOwner implements AuctionContextRegistry { this.dispose(); return Object.freeze({}); } - const output: Record = {}; + const accepted = new Map(); + let acceptedEntryBytes = 0; + let acceptedKeyBytes = 0; + let acceptedStructureEntries = 1; for (const integrationId of this.manifestIntegrationIds) { const record = this.registrations.get(integrationId); - if (!record || !this.ownerIsCurrent(record.owner, record.ownerGeneration)) continue; - let contribution: Readonly> | undefined; + if (!record) continue; + const ownerIsCurrentBeforeInvocation = this.ownerIsCurrent( + record.owner, + record.ownerGeneration + ); + const recordSurvivedOwnerReflection = this.registrations.get(integrationId) === record; + if (!ownerIsCurrentBeforeInvocation || !recordSurvivedOwnerReflection) continue; + let contribution: CopiedContribution | undefined; + let contributorReturnedUndefined = false; try { const candidate = record.contributor(); - if (candidate === undefined) continue; - contribution = copyContribution(candidate); + contributorReturnedUndefined = candidate === undefined; + if (!contributorReturnedUndefined) contribution = copyContribution(candidate); } catch { contribution = undefined; } + if (this.registrations.get(integrationId) !== record) continue; + if (contributorReturnedUndefined) continue; if (!contribution) { this.reportFailure(integrationId); continue; } - if (!this.runtimeIsCurrent()) return Object.freeze({}); - if (!this.ownerIsCurrent(record.owner, record.ownerGeneration)) continue; - for (const [key, value] of Object.entries(contribution)) { + const runtimeIsCurrent = this.runtimeIsCurrent(); + const recordSurvivedRuntimeReflection = this.registrations.get(integrationId) === record; + if (!runtimeIsCurrent) return Object.freeze({}); + if (!recordSurvivedRuntimeReflection) continue; + const ownerIsCurrentBeforeMerge = this.ownerIsCurrent(record.owner, record.ownerGeneration); + const recordSurvivedOwnerReflectionBeforeMerge = + this.registrations.get(integrationId) === record; + if (!ownerIsCurrentBeforeMerge || !recordSurvivedOwnerReflectionBeforeMerge) continue; + let prospectiveEntryBytes = acceptedEntryBytes; + let prospectiveKeyBytes = acceptedKeyBytes; + let prospectiveStructureEntries = acceptedStructureEntries; + let prospectiveEntryCount = accepted.size; + for (const entry of contribution.entries) { + const predecessor = accepted.get(entry.key); + if (predecessor) { + prospectiveEntryBytes -= predecessor.contribution.jsonBytes; + prospectiveKeyBytes -= predecessor.contribution.keyBytes; + prospectiveStructureEntries -= predecessor.contribution.structureEntries; + } else prospectiveEntryCount += 1; + prospectiveEntryBytes += entry.jsonBytes; + prospectiveKeyBytes += entry.keyBytes; + prospectiveStructureEntries += entry.structureEntries; + } + const prospectiveJsonBytes = + 2 + Math.max(0, prospectiveEntryCount - 1) + prospectiveEntryBytes; + if ( + prospectiveJsonBytes > MAX_CONTEXT_JSON_BYTES || + prospectiveKeyBytes > MAX_CONTEXT_ENCODED_KEY_BYTES || + prospectiveStructureEntries > MAX_CONTEXT_STRUCTURE_ENTRIES + ) { + this.reportFailure(integrationId); + continue; + } + if (this.registrations.get(integrationId) !== record) continue; + for (const entry of contribution.entries) { + accepted.set(entry.key, Object.freeze({ contribution: entry, integrationId, record })); + } + acceptedEntryBytes = prospectiveEntryBytes; + acceptedKeyBytes = prospectiveKeyBytes; + acceptedStructureEntries = prospectiveStructureEntries; + } + try { + const output: Record = {}; + for (const [key, entry] of accepted) { Object.defineProperty(output, key, { configurable: true, enumerable: true, - value, + value: entry.contribution.value, writable: true, }); } + if (!this.runtimeIsCurrent()) return Object.freeze({}); + for (const entry of accepted.values()) { + if (this.registrations.get(entry.integrationId) !== entry.record) { + return Object.freeze({}); + } + } + return Object.freeze(output); + } catch { + return Object.freeze({}); } - return this.runtimeIsCurrent() ? Object.freeze(output) : Object.freeze({}); } public dispose(): void { @@ -247,11 +534,9 @@ class AuctionContextRegistryOwner implements AuctionContextRegistry { } private runtimeIsCurrent(): boolean { - return ( - !this.isDisposed && - this.runtimeOwner.generation === this.runtimeGeneration && - this.ownerIsCurrent(this.runtimeOwner, this.runtimeGeneration) - ); + const generation = this.runtimeGeneration; + if (this.isDisposed || generation === undefined) return false; + return this.ownerIsCurrent(this.runtimeOwner, generation) && !this.isDisposed; } private ownerIsCurrent(owner: ContextContributorOwner, generation: object): boolean { @@ -262,6 +547,22 @@ class AuctionContextRegistryOwner implements AuctionContextRegistry { } } + private snapshotCurrentOwnerGeneration(owner: ContextContributorOwner): object | undefined { + try { + const generation = owner.generation; + if (typeof generation !== 'object' || generation === null) return undefined; + return owner.isCurrent() ? generation : undefined; + } catch { + return undefined; + } + } + + private deleteRegistration(integrationId: string, record: ContributorRecord): void { + if (this.registrations.get(integrationId) === record) { + this.registrations.delete(integrationId); + } + } + private reportFailure(integrationId: string): void { try { this.onContributorFailure?.(Object.freeze({ integrationId, reason: 'contributor_failed' })); diff --git a/crates/trusted-server-js/lib/test/kernel/identity.test.ts b/crates/trusted-server-js/lib/test/kernel/identity.test.ts index 18db61ef9..6c05f3244 100644 --- a/crates/trusted-server-js/lib/test/kernel/identity.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/identity.test.ts @@ -55,6 +55,98 @@ describe('navigation identity issuer', () => { 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(); diff --git a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts index ddd1b60b3..05a81ca7e 100644 --- a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -126,6 +126,51 @@ describe('Runtime bootstrap owner', () => { ).toBe(false); }); + 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({ + id: 'gpt', + release: 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; diff --git a/crates/trusted-server-js/lib/test/kernel/sessions.test.ts b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts index f64abec5e..f958acd03 100644 --- a/crates/trusted-server-js/lib/test/kernel/sessions.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts @@ -209,6 +209,63 @@ describe('runtime and navigation sessions', () => { }); }); + 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(); diff --git a/crates/trusted-server-js/lib/test/services/context.test.ts b/crates/trusted-server-js/lib/test/services/context.test.ts index 57cb48eec..67188784a 100644 --- a/crates/trusted-server-js/lib/test/services/context.test.ts +++ b/crates/trusted-server-js/lib/test/services/context.test.ts @@ -5,6 +5,10 @@ import { 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)[] = []; @@ -119,6 +123,263 @@ describe('AuctionContextRegistry', () => { 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 }; @@ -140,6 +401,261 @@ describe('AuctionContextRegistry', () => { 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(() => { @@ -183,6 +699,183 @@ describe('AuctionContextRegistry', () => { 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('fails closed for a manifest beyond the integration bound', () => { const ids = Object.freeze(Array.from({ length: 17 }, (_, index) => `integration-${index}`)); From 666980005a89564e19eaabc11a08bf69d181eadd Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:57:17 -0700 Subject: [PATCH 027/194] Add bounded external readiness adapters --- .../lib/src/adapters/googletag.ts | 1117 ++++++++++++++++- .../lib/src/adapters/messaging.ts | 994 ++++++++++++++- .../lib/src/adapters/prebid.ts | 1024 ++++++++++++++- .../lib/src/composition/browser.ts | 27 +- .../lib/test/adapters/googletag.test.ts | 1094 ++++++++++++++++ .../lib/test/adapters/messaging.test.ts | 673 ++++++++++ .../lib/test/adapters/prebid.test.ts | 1033 +++++++++++++++ .../lib/test/composition/browser.test.ts | 118 +- 8 files changed, 6031 insertions(+), 49 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/adapters/googletag.test.ts create mode 100644 crates/trusted-server-js/lib/test/adapters/messaging.test.ts create mode 100644 crates/trusted-server-js/lib/test/adapters/prebid.test.ts diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index 945ae1011..b71c8694b 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -1,9 +1,68 @@ +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; + } +} + +/** The small GPT surface exposed to an accepted operation. */ +export interface GoogletagFacade { + clearTargeting(slot: object, key?: string): unknown; + display(slot: string | object): unknown; + getTargeting(slot: object, key: string): readonly string[]; + 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; + slots(): readonly object[]; + subscribe(eventType: string, listener: (event: unknown) => void): () => void; +} + +/** 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; + run( + command: (googletag: Readonly) => T, + options?: GoogletagOperationOptions + ): GoogletagOperation; + notifyReady(): void; + dispose(): void; } /** Browser surface owned by the concrete GPT adapter. */ @@ -11,23 +70,1067 @@ export interface GoogletagGlobalTarget { googletag?: unknown; } -function bindingStatus(value: unknown): GoogletagBindingStatus { - if (value === undefined || value === null) return 'pending'; - return typeof value === 'object' || typeof value === 'function' ? 'present' : 'incompatible'; +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; + 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>; +} + +const sharedInitialLoadTrackers = new WeakMap(); + +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 +): 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 = Reflect.apply(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', [])); + return Object.freeze({ + clearTargeting: (slot: object, key?: string): unknown => + call(slot, 'clearTargeting', key === undefined ? [] : [key]), + display: (slot: string | object): unknown => call(binding.binding, 'display', [slot]), + 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]); + }, + 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]), + 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) => void): (() => void) => { + const currentService = service(); + const add = member(currentService, 'addEventListener'); + const remove = member(currentService, 'removeEventListener'); + const wrapped = (event: unknown): void => { + if (!isBindingCurrent()) return; + try { + listener(event); + } catch { + // Publisher and service callbacks cannot escape the GPT boundary. + } + }; + 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(); + }); + }, + }); } /** Create the sole production reader/writer boundary for `window.googletag`. */ export function createBrowserGoogletagAdapter( target: GoogletagGlobalTarget = window as unknown as GoogletagGlobalTarget ): GoogletagAdapter { + const pending: PendingOperation[] = []; + const live = new Set>(); + const effects = new Set<() => void>(); + const armedBindings = new WeakSet(); + const initialLoadReleases = new Map void>(); + const initialLoadOwner = Object.freeze({}); + let disposed = false; + + const registerAdapterEffect = (disposeEffect: () => void): void => { + if (disposed) { + try { + disposeEffect(); + } catch { + // Reentrant disposal keeps newly-created effects from escaping the adapter. + } + return; + } + effects.add(disposeEffect); + }; + + 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 = initialLoadReleases.get(binding); + if (!release) return; + initialLoadReleases.delete(binding); + try { + effects.delete(release); + } catch { + // A hostile registry cannot retain adapter ownership of an old binding. + } + try { + release(); + } catch { + // One historical binding cannot interrupt release of later bindings. + } + }; + + const releaseHistoricalInitialLoadBindings = (current?: object): void => { + for (const binding of [...initialLoadReleases.keys()]) { + if (binding !== current) releaseInitialLoadBinding(binding); + } + }; + + 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 = sharedInitialLoadTrackers.get(expected.binding); + if (!tracker) { + tracker = { + disabled: false, + rootWrapped: false, + owners: new Set(), + restorers: new Set<() => void>(), + services: new WeakMap void>(), + }; + sharedInitialLoadTrackers.set(expected.binding, tracker); + } + const trackingCurrent = (): boolean => { + if ( + disposed || + sharedInitialLoadTrackers.get(expected.binding) !== tracker || + !tracker.owners.has(initialLoadOwner) + ) { + return false; + } + const current = sameBinding(expected); + return ( + !disposed && + current && + sharedInitialLoadTrackers.get(expected.binding) === tracker && + tracker.owners.has(initialLoadOwner) + ); + }; + let adoptedHere = false; + if (!initialLoadReleases.has(expected.binding)) { + if (!expectedCurrent()) { + if ( + tracker.owners.size === 0 && + sharedInitialLoadTrackers.get(expected.binding) === tracker + ) { + sharedInitialLoadTrackers.delete(expected.binding); + } + return undefined; + } + tracker.owners.add(initialLoadOwner); + const adoptedTracker = tracker; + const release = (): void => { + if (initialLoadReleases.get(expected.binding) === release) { + initialLoadReleases.delete(expected.binding); + } + if (!adoptedTracker.owners.delete(initialLoadOwner) || adoptedTracker.owners.size > 0) { + return; + } + if (sharedInitialLoadTrackers.get(expected.binding) === adoptedTracker) { + sharedInitialLoadTrackers.delete(expected.binding); + } + for (const restore of [...adoptedTracker.restorers].reverse()) { + try { + restore(); + } catch { + // One restoration cannot interrupt cleanup of the shared tracker. + } + } + adoptedTracker.restorers.clear(); + }; + initialLoadReleases.set(expected.binding, release); + 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; + tracker!.restorers.delete(cleanup); + tracker!.rootWrapped = false; + restore(); + }; + tracker.rootWrapped = true; + tracker.restorers.add(cleanup); + installedHere.push(cleanup); + } + if (!trackingCurrent()) return rollback(); + } + } + + const trackService = (service: object): boolean => { + if (tracker!.services.has(service)) return true; + 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; + tracker!.restorers.delete(cleanup); + tracker!.services.delete(service); + restore(); + }; + tracker!.services.set(service, cleanup); + tracker!.restorers.add(cleanup); + installedHere.push(cleanup); + } + if (!trackingCurrent()) return false; + return true; + }; + + if (knownService) { + if (!trackService(knownService)) return rollback(); + } else { + if (!trackingCurrent()) return rollback(); + try { + const service = Reflect.apply(expected.pubads, expected.binding, []); + if (!trackingCurrent()) return rollback(); + if ((typeof service === 'object' && service !== null) || typeof service === 'function') { + if (!trackService(service as object)) return rollback(); + } + } catch { + 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 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 && + 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 clearReadiness = (operation: PendingOperation): void => { + if (operation.timeout !== undefined) { + clearTimeout(operation.timeout); + operation.timeout = undefined; + } + removePending(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 { + live.delete(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); + ensureInitialLoadTracking(binding); + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + if (operation.settled) return; + 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 { + effects.delete(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) + ); + }; + const facade = createFacade( + binding, + registerOperationEffect, + isDispatchCurrent, + () => !disposed && sameBinding(binding), + (service) => { + const tracker = ensureInitialLoadTracking(binding, service); + return tracker?.disabled === true; + } + ); + try { + 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 || + armedBindings.has(current.binding) + ) { + return; + } + for (const operation of pending) operation.readinessBinding = current.binding; + armedBindings.add(current.binding); + try { + queueCommand(current.commandQueue, () => notifyReady(current.binding)); + } catch { + // A later script-owned notification or operation may observe a replacement. + } + }; + + 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' && pending.length >= MAX_PENDING_OPERATIONS) { + throw new GoogletagAdapterError('external_queue_full'); + } + + 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, + timeout: undefined, + command, + resolve, + reject, + abortRegistration: undefined, + readinessBinding: current.status === 'pending' ? current.binding : undefined, + provisionalEffects: [], + }; + live.add(operation as PendingOperation); + const handle = Object.freeze({ + get status(): GoogletagOperationStatus { + return operation.state; + }, + result, + dispose: (): void => fail(operation as PendingOperation, 'operation_disposed'), + }); + + if (current.status === 'pending') { + pending.push(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; + } + } + + 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; + }; + return Object.freeze({ - bindingStatus: () => bindingStatus(target.googletag), + bindingStatus: (): GoogletagBindingStatus => currentBinding().status, + run, + notifyReady, + dispose: (): void => { + if (disposed) return; + disposed = true; + for (const operation of [...live]) fail(operation, 'operation_disposed'); + for (const binding of [...initialLoadReleases.keys()]) { + releaseInitialLoadBinding(binding); + } + initialLoadReleases.clear(); + for (const disposeEffect of [...effects]) { + try { + effects.delete(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 Object.freeze({ - bindingStatus: () => 'pending', - }); + return createBrowserGoogletagAdapter({}); } diff --git a/crates/trusted-server-js/lib/src/adapters/messaging.ts b/crates/trusted-server-js/lib/src/adapters/messaging.ts index d0f2912a3..b31a5e001 100644 --- a/crates/trusted-server-js/lib/src/adapters/messaging.ts +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -1,3 +1,163 @@ +const MAX_GLOBAL_MESSAGE_BYTES = 4_096; + +/** 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. */ @@ -6,9 +166,794 @@ export interface MessageEventTarget { removeEventListener(type: 'message', listener: CaptureMessageListener, capture: true): void; } +/** A narrow owned endpoint for one transferred browser message port. */ +export interface MessagingPort { + post(message: unknown, transferred: readonly unknown[]): void; + listen( + messageListener: (event: unknown) => void, + messageErrorListener: (event: unknown) => void + ): () => void; + close(): void; +} + /** Cross-window boundary consumed by the kernel's capability recognizer. */ export interface MessagingAdapter { installCaptureListener(listener: CaptureMessageListener): () => void; + parseProtocolMessage( + kind: ProtocolMessageKind, + candidate: unknown + ): Readonly> | undefined; + extractTransferredPorts( + event: unknown, + expectedCount: 0 | 1 | 2 + ): 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 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 RawPort { + readonly binding: object; + readonly add: (...arguments_: unknown[]) => unknown; + readonly closePort: (...arguments_: unknown[]) => unknown; + readonly postMessage: (...arguments_: unknown[]) => unknown; + readonly remove: (...arguments_: unknown[]) => unknown; + readonly start?: (...arguments_: unknown[]) => unknown; +} + +function rawPort(candidate: unknown): RawPort | undefined { + if ((typeof candidate !== 'object' || candidate === null) && typeof candidate !== 'function') { + return undefined; + } + try { + const add = Reflect.get(candidate, 'addEventListener'); + const closePort = Reflect.get(candidate, 'close'); + const postMessage = Reflect.get(candidate, 'postMessage'); + const remove = Reflect.get(candidate, 'removeEventListener'); + const start = Reflect.get(candidate, 'start'); + if ( + typeof add !== 'function' || + typeof closePort !== 'function' || + typeof postMessage !== 'function' || + typeof remove !== 'function' || + (start !== undefined && typeof start !== 'function') + ) { + return undefined; + } + return { binding: candidate, add, closePort, postMessage, remove, start }; + } catch { + return 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 wrapPort(raw: RawPort): MessagingPort { + const listeners = new Set<() => void>(); + let closed = false; + return Object.freeze({ + post: (message: unknown, transferred: readonly unknown[]): void => { + if (closed) return; + try { + Reflect.apply(raw.postMessage, raw.binding, [message, [...transferred]]); + } catch { + // A failed post remains local to the channel boundary. + } + }, + listen: ( + messageListener: (event: unknown) => void, + messageErrorListener: (event: unknown) => void + ): (() => void) => { + if (closed) return () => undefined; + const wrappedMessage = (event: unknown): void => { + if (closed) return; + try { + messageListener(event); + } catch { + // Channel callbacks cannot escape the messaging boundary. + } + }; + const wrappedMessageError = (event: unknown): void => { + if (closed) 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; + listeners.delete(dispose); + if (!setupInProgress) rollback(); + }; + const stopClosedSetup = (): boolean => { + if (!closed && active) return false; + setupInProgress = false; + rollback(); + return true; + }; + listeners.add(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; + listeners.delete(dispose); + rollback(); + return dispose; + } + setupInProgress = false; + return dispose; + }, + close: (): void => { + if (closed) return; + closed = true; + for (const dispose of [...listeners]) dispose(); + try { + Reflect.apply(raw.closePort, raw.binding, []); + } catch { + // Closing remains best-effort and idempotent. + } + }, + }); +} + +function closeUniquePorts(candidates: readonly unknown[]): void { + const closed: object[] = []; + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + if ((typeof candidate !== 'object' || candidate === null) && typeof candidate !== 'function') { + continue; + } + let duplicate = false; + for (let closedIndex = 0; closedIndex < closed.length; closedIndex += 1) { + if (closed[closedIndex] === candidate) { + duplicate = true; + break; + } + } + if (duplicate) continue; + closed[closed.length] = candidate as object; + closeRawPort(candidate); + } +} + +function snapshotPortArray( + candidate: unknown +): { 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 descriptors = Object.getOwnPropertyDescriptors(candidate); + const ownKeys = Reflect.ownKeys(candidate); + const values: unknown[] = []; + let valid = length <= 2 && ownKeys.length === length + 1; + const numericKeys = ownKeys + .filter( + (key): key is string => + typeof key === 'string' && + /^(?:0|[1-9]\d*)$/.test(key) && + Number(key) < length && + Number.isSafeInteger(Number(key)) + ) + .sort((left, right) => Number(left) - Number(right)); + if (numericKeys.length !== length) valid = false; + for (const key of numericKeys) { + const descriptor = descriptors[key]; + if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + valid = false; + continue; + } + values.push(descriptor.value); + } + return { valid, values }; + } catch { + return undefined; + } +} + +function extractTransferredPorts( + event: unknown, + expectedCount: 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; + if (!snapshot.valid || snapshot.values.length !== expectedCount) { + closeUniquePorts(snapshot.values); + return undefined; + } + if (expectedCount === 2 && snapshot.values[0] === snapshot.values[1]) { + closeRawPort(snapshot.values[0]); + return undefined; + } + const ports: RawPort[] = []; + for (let index = 0; index < snapshot.values.length; index += 1) { + const port = rawPort(snapshot.values[index]); + if (!port) { + closeUniquePorts(snapshot.values); + return undefined; + } + ports.push(port); + } + const wrapped: MessagingPort[] = []; + for (const port of ports) wrapped.push(wrapPort(port)); + return Object.freeze(wrapped); } /** @@ -18,19 +963,51 @@ export interface MessagingAdapter { * capability message before any integration activation or TS-owned injection. */ export function createBrowserMessagingAdapter( - target: MessageEventTarget = window as unknown as MessageEventTarget + target: MessageEventTarget = window as unknown as MessageEventTarget, + validation: MessagingValidationOptions = {} ): MessagingAdapter { return Object.freeze({ installCaptureListener(listener: CaptureMessageListener): () => void { - target.addEventListener('message', listener, true); - let installed = true; - + 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 () => { - if (!installed) return; - installed = false; - target.removeEventListener('message', listener, true); + rollback(); }; }, + parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => + parseProtocolMessage(kind, candidate, validation), + extractTransferredPorts, }); } @@ -38,5 +1015,8 @@ export function createBrowserMessagingAdapter( export function createNoopMessagingAdapter(): MessagingAdapter { return Object.freeze({ installCaptureListener: () => () => undefined, + parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => + parseProtocolMessage(kind, candidate, {}), + extractTransferredPorts, }); } diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index f44cc8215..b29961b0e 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -1,9 +1,91 @@ +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; + /** 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; + } +} + +/** 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[]; + }>[]; +} + +/** The small Prebid surface exposed to an accepted operation. */ +export interface PrebidFacade { + addAdUnits(adUnits: readonly unknown[]): unknown; + addBidResponse(adUnitCode: string, bid: object): unknown; + highestBids(adUnitCode?: string): readonly object[]; + processQueue(): unknown; + renderAd(targetDocument: object, adId: string): unknown; + requestBids(options: object): unknown; + subscribe(eventType: string, listener: (event: unknown) => 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; + run( + command: (prebid: Readonly) => T, + options?: PrebidOperationOptions + ): PrebidOperation; + notifyReady(): void; + dispose(): void; } /** Browser surface owned by the concrete Prebid adapter. */ @@ -11,23 +93,949 @@ export interface PrebidGlobalTarget { pbjs?: unknown; } -function bindingStatus(value: unknown): PrebidBindingStatus { - if (value === undefined || value === null) return 'pending'; - return typeof value === 'object' || typeof value === 'function' ? 'present' : 'incompatible'; +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; + 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[]; +} + +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 || + Object.getPrototypeOf(value) !== Object.prototype + ) { + 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) || Object.getPrototypeOf(value) !== Array.prototype) 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; +} + +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 || + stamp.prebidVersion !== '10.26.0' || + 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; + } +} + +const REQUIRED_API_METHODS = [ + 'addAdUnits', + 'addBidResponse', + 'getHighestCpmBids', + 'offEvent', + 'onEvent', + 'processQueue', + 'renderAd', + 'requestBids', +] 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 + target: PrebidGlobalTarget = window as unknown as PrebidGlobalTarget, + requirements: PrebidArtifactRequirements = {} ): PrebidAdapter { + const pending: PendingOperation[] = []; + const live = new Set>(); + const effects = new Set<() => void>(); + const armedBindings = new WeakSet(); + const diagnosedBindings = new WeakSet(); + let diagnosedUnbound = false; + let disposed = false; + + const currentBinding = (): ReturnType => { + const inspected = inspectBinding(readTarget(target), requirements); + if (inspected.status === 'incompatible') { + const shouldDiagnose = inspected.binding + ? !diagnosedBindings.has(inspected.binding) + : !diagnosedUnbound; + if (shouldDiagnose) { + if (inspected.binding) diagnosedBindings.add(inspected.binding); + else diagnosedUnbound = true; + 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 createFacade = ( + binding: PresentPrebid, + registerOperationEffect: (disposeEffect: () => void) => () => void, + isOperationCurrent: () => boolean, + isBindingCurrent: () => boolean + ): Readonly => + Object.freeze({ + addAdUnits: (adUnits: readonly unknown[]): unknown => + callBound(binding, 'addAdUnits', [[...adUnits]], isOperationCurrent), + addBidResponse: (adUnitCode: string, bid: object): unknown => + callBound(binding, 'addBidResponse', [adUnitCode, bid], isOperationCurrent), + highestBids: (adUnitCode?: string): readonly object[] => { + const value = callBound( + binding, + 'getHighestCpmBids', + adUnitCode === undefined ? [] : [adUnitCode], + isOperationCurrent + ); + if (!Array.isArray(value) || value.some((bid) => typeof bid !== 'object' || bid === null)) { + throw new PrebidAdapterError('external_artifact_incompatible'); + } + if (!isOperationCurrent()) throw new PrebidAdapterError('external_artifact_incompatible'); + return Object.freeze([...value]); + }, + processQueue: (): unknown => callBound(binding, 'processQueue', [], isOperationCurrent), + renderAd: (targetDocument: object, adId: string): unknown => + callBound(binding, 'renderAd', [targetDocument, adId], isOperationCurrent), + requestBids: (options: object): unknown => + callBound(binding, 'requestBids', [options], isOperationCurrent), + subscribe: (eventType: string, listener: (event: unknown) => 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; + try { + listener(event); + } catch { + // Publisher callbacks cannot escape the Prebid boundary. + } + }; + 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 clearReadiness = (operation: PendingOperation): void => { + if (operation.timeout !== undefined) { + clearTimeout(operation.timeout); + operation.timeout = undefined; + } + removePending(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 { + live.delete(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 { + effects.delete(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 || + armedBindings.has(current.binding) + ) { + return; + } + for (const operation of pending) operation.readinessBinding = current.binding; + armedBindings.add(current.binding); + try { + queueCommand(current.commandQueue, () => notifyReady(current.binding)); + } catch { + // A later script-owned notification or operation may observe a replacement. + } + }; + + 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' && pending.length >= MAX_PENDING_OPERATIONS) { + throw new PrebidAdapterError('external_queue_full'); + } + + 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, + timeout: undefined, + command, + resolve, + reject, + abortRegistration: undefined, + readinessBinding: current.status === 'pending' ? current.binding : undefined, + provisionalEffects: [], + }; + live.add(operation as PendingOperation); + const handle = Object.freeze({ + get status(): PrebidOperationStatus { + return operation.state; + }, + result, + dispose: (): void => fail(operation as PendingOperation, 'operation_disposed'), + }); + + if (current.status === 'pending') { + pending.push(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; + } + } + + 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({ - bindingStatus: () => bindingStatus(target.pbjs), + 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 { + effects.delete(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 Object.freeze({ - bindingStatus: () => 'pending', - }); + return createBrowserPrebidAdapter({}); } diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 21d79b3dc..38e77515b 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -9,6 +9,7 @@ import { createNoopMessagingAdapter, type MessageEventTarget, type MessagingAdapter, + type MessagingValidationOptions, } from '../adapters/messaging'; import { createBrowserPrebidAdapter, @@ -17,6 +18,7 @@ import { type PrebidGlobalTarget, } from '../adapters/prebid'; import { parseBrowserAuctionProjectionV1 } from '../core/contracts/auction_projection'; +import { validateApsRenderer } from '../core/contracts/aps_renderer'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; import { createRuntimeSession } from '../kernel/sessions'; @@ -45,6 +47,7 @@ export type BrowserAdapterTarget = GoogletagGlobalTarget & PrebidGlobalTarget & export interface BrowserCompositionOptions { readonly adapters?: Partial; + readonly messagingValidation?: MessagingValidationOptions; readonly target?: BrowserAdapterTarget; } @@ -189,6 +192,21 @@ function projectionSlots(projection: object): readonly string[] { 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 @@ -197,8 +215,11 @@ export function createBrowserComposition( const messaging = options.adapters?.messaging ?? (options.target - ? createBrowserMessagingAdapter(options.target) - : createBrowserMessagingAdapter()); + ? createBrowserMessagingAdapter(options.target, { + validateApsRenderer: defaultValidator, + ...options.messagingValidation, + }) + : createBrowserMessagingAdapter(undefined, browserMessagingValidation())); const prebid = options.adapters?.prebid ?? (options.target ? createBrowserPrebidAdapter(options.target) : createBrowserPrebidAdapter()); @@ -252,6 +273,8 @@ export function createTestBrowserRuntimeComposition( }); context.onDispose(() => { session.dispose(); + composition.adapters.googletag.dispose(); + composition.adapters.prebid.dispose(); if (runtimeSession === session) { runtimeSession = undefined; projectionSlotLedger = undefined; diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts new file mode 100644 index 000000000..4ac71330d --- /dev/null +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -0,0 +1,1094 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createBrowserGoogletagAdapter } from '../../src/adapters/googletag'; + +type Command = () => void; + +function createReadyGoogletag( + options: { readonly deferCommands?: boolean; readonly initialLoadDisabled?: boolean } = {} +) { + const commands: Command[] = []; + const display = vi.fn(); + const initialLoad = { disabled: options.initialLoadDisabled === true }; + const listeners = new Map void>>(); + const pubads = { + addEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + const registered = listeners.get(type) ?? new Set(); + registered.add(listener); + listeners.set(type, registered); + }), + disableInitialLoad: vi.fn(() => { + initialLoad.disabled = true; + return 'legacy-result'; + }), + getSlots: vi.fn<() => object[]>(() => []), + refresh: vi.fn(), + removeEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + listeners.get(type)?.delete(listener); + }), + }; + const googletag = { + apiReady: true, + pubadsReady: true, + cmd: { + push: vi.fn((command: Command): number => { + if (options.deferCommands) commands.push(command); + else command(); + return commands.length; + }), + }, + display, + getConfig: vi.fn((key: string) => + key === 'disableInitialLoad' ? { disableInitialLoad: initialLoad.disabled } : {} + ), + pubads: vi.fn(() => pubads), + setConfig: vi.fn((config: { readonly disableInitialLoad?: boolean | null }) => { + if (Object.prototype.hasOwnProperty.call(config, 'disableInitialLoad')) { + initialLoad.disabled = config.disableInitialLoad === true; + } + return 'config-result'; + }), + }; + return { commands, display, googletag, initialLoad, listeners, pubads }; +} + +describe('browser googletag adapter readiness', () => { + afterEach(() => vi.useRealTimers()); + + it('reports present and gives the command only a frozen narrow facade', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const operation = adapter.run((gpt) => { + expect(Object.isFrozen(gpt)).toBe(true); + expect('cmd' in gpt).toBe(false); + expect('apiReady' in gpt).toBe(false); + gpt.display('slot-a'); + return 'completed'; + }); + + expect(operation.status).toBe('present'); + await expect(operation.result).resolves.toBe('completed'); + expect(ready.display).toHaveBeenCalledWith('slot-a'); + }); + + it('reports pending and drains live operations FIFO through a real GPT command notification', async () => { + const readinessCommands: Command[] = []; + const target: { googletag?: unknown } = { + googletag: { cmd: readinessCommands }, + }; + const adapter = createBrowserGoogletagAdapter(target); + const order: number[] = []; + const first = adapter.run(() => order.push(1)); + const second = adapter.run(() => order.push(2)); + + expect(first.status).toBe('pending'); + expect(second.status).toBe('pending'); + expect(readinessCommands).toHaveLength(1); + + target.googletag = createReadyGoogletag().googletag; + readinessCommands[0]?.(); + + await expect(first.result).resolves.toBe(1); + await expect(second.result).resolves.toBe(2); + expect(order).toEqual([1, 2]); + expect(first.status).toBe('present'); + expect(second.status).toBe('present'); + }); + + it('rejects a queued operation when its pending GPT stub becomes incompatible', async () => { + const readinessCommands: Command[] = []; + const ready = createReadyGoogletag(); + const binding: Record = { + ...ready.googletag, + apiReady: false, + cmd: readinessCommands, + }; + const adapter = createBrowserGoogletagAdapter({ googletag: binding }); + const command = vi.fn(); + const operation = adapter.run(command); + + binding['apiReady'] = true; + delete binding['display']; + readinessCommands[0]?.(); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(operation.status).toBe('incompatible'); + expect(command).not.toHaveBeenCalled(); + }); + + it('requires the captured GPT queue and root methods to remain compatible', async () => { + const mutations = [ + (binding: Record): void => { + binding['apiReady'] = false; + }, + (binding: Record): void => { + binding['display'] = vi.fn(); + }, + (binding: Record): void => { + binding['pubads'] = vi.fn(); + }, + (binding: Record): void => { + binding['cmd'] = { push: vi.fn() }; + }, + ]; + for (const mutate of mutations) { + const ready = createReadyGoogletag({ deferCommands: true }); + const binding = ready.googletag as unknown as Record; + const adapter = createBrowserGoogletagAdapter({ googletag: binding }); + const command = vi.fn(); + const operation = adapter.run(command); + + mutate(binding); + ready.commands[0]?.(); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(operation.status).toBe('incompatible'); + expect(command).not.toHaveBeenCalled(); + } + }); + + it('rechecks GPT compatibility after an in-place getter mutation', async () => { + const ready = createReadyGoogletag({ deferCommands: true }); + const binding = ready.googletag as unknown as Record; + const originalDisplay = ready.googletag.display; + const adapter = createBrowserGoogletagAdapter({ googletag: binding }); + const command = vi.fn(); + const operation = adapter.run(command); + Object.defineProperty(binding, 'display', { + configurable: true, + get: () => { + binding['apiReady'] = false; + return originalDisplay; + }, + }); + + ready.commands[0]?.(); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(command).not.toHaveBeenCalled(); + }); + + it('ignores a stale GPT notification and lets the replacement notification decide', async () => { + const oldNotifications: Command[] = []; + const replacementNotifications: Command[] = []; + const oldBinding = { cmd: oldNotifications }; + const replacement: Record = { cmd: replacementNotifications }; + const target: { googletag?: unknown } = { googletag: oldBinding }; + const adapter = createBrowserGoogletagAdapter(target); + const first = adapter.run(() => 'first'); + target.googletag = replacement; + const second = adapter.run(() => 'second'); + + replacement['apiReady'] = true; + oldNotifications[0]?.(); + expect(first.status).toBe('pending'); + expect(second.status).toBe('pending'); + + replacementNotifications[0]?.(); + await expect(first.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + await expect(second.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + }); + + it('does not let a stale GPT notification condemn a primitive replacement', async () => { + const oldNotifications: Command[] = []; + const target: { googletag?: unknown } = { googletag: { cmd: oldNotifications } }; + const adapter = createBrowserGoogletagAdapter(target); + const operation = adapter.run(vi.fn()); + const result = operation.result.catch((error: unknown) => error); + target.googletag = 1; + + oldNotifications[0]?.(); + expect(operation.status).toBe('pending'); + adapter.dispose(); + await expect(result).resolves.toMatchObject({ code: 'operation_disposed' }); + }); + + it('marks only the current operation incompatible and permits a later replacement', async () => { + const target = { googletag: { apiReady: true, cmd: {} } }; + const adapter = createBrowserGoogletagAdapter(target); + const incompatible = adapter.run(vi.fn()); + + expect(incompatible.status).toBe('incompatible'); + await expect(incompatible.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + + target.googletag = createReadyGoogletag().googletag; + const replacement = adapter.run(() => 'replacement'); + expect(replacement.status).toBe('present'); + await expect(replacement.result).resolves.toBe('replacement'); + }); + + it('holds 64 pending operations and fails only overflow synchronously', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const operations = Array.from({ length: 64 }, () => adapter.run(() => undefined)); + + expect(operations.every(({ status }) => status === 'pending')).toBe(true); + expect(() => adapter.run(() => undefined)).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + await expect(Promise.all(operations.map(({ result }) => result))).resolves.toHaveLength(64); + }); + + it('reserves pending GPT capacity before hostile signal registration reenters', async () => { + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const accepted: Array> = []; + const overflows: unknown[] = []; + const order: number[] = []; + const signal = { + aborted: false, + addEventListener: vi.fn(() => { + for (let index = 1; index <= 64; index += 1) { + try { + accepted.push(adapter.run(() => order.push(index))); + } catch (error) { + overflows.push(error); + } + } + }), + removeEventListener: vi.fn(), + } as unknown as AbortSignal; + + const outer = adapter.run(() => order.push(0), { signal }); + + expect(accepted).toHaveLength(63); + expect(overflows).toHaveLength(1); + expect(overflows[0]).toMatchObject({ code: 'external_queue_full' }); + + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + await expect( + Promise.all([outer.result, ...accepted.map(({ result }) => result)]) + ).resolves.toHaveLength(64); + expect(order).toEqual(Array.from({ length: 64 }, (_, index) => index)); + }); + + it.each(['signal-getter', 'aborted-getter', 'add-throw', 'abort-remove-throw'] as const)( + 'contains hostile GPT AbortSignal ownership for %s', + async (failure) => { + const adapter = createBrowserGoogletagAdapter({}); + const signalError = new Error(`signal failure: ${failure}`); + const listeners = new Set<() => void>(); + const removeEventListener = vi.fn((_type: string, listener: () => void) => { + if (failure === 'abort-remove-throw') throw signalError; + listeners.delete(listener); + }); + const signal = Object.defineProperties( + {}, + { + aborted: { + get: () => { + if (failure === 'aborted-getter') throw signalError; + return false; + }, + }, + addEventListener: { + value: vi.fn((_type: string, listener: () => void) => { + listeners.add(listener); + if (failure === 'add-throw') throw signalError; + if (failure === 'abort-remove-throw') listener(); + }), + }, + removeEventListener: { value: removeEventListener }, + } + ) as AbortSignal; + const options = + failure === 'signal-getter' + ? (Object.defineProperty({}, 'signal', { + get: () => { + throw signalError; + }, + }) as { readonly signal?: AbortSignal }) + : { signal }; + let operation: ReturnType | undefined; + + expect(() => { + operation = adapter.run(vi.fn(), options); + }).not.toThrow(); + if (!operation) throw new Error('Expected a published GPT operation'); + if (failure === 'abort-remove-throw') { + await expect(operation.result).rejects.toMatchObject({ code: 'caller_aborted' }); + } else { + await expect(operation.result).rejects.toBe(signalError); + } + if (failure === 'add-throw' || failure === 'abort-remove-throw') { + expect(removeEventListener).toHaveBeenCalledTimes(1); + } + + const fillers: Array> = []; + for (let index = 0; index < 64; index += 1) fillers.push(adapter.run(vi.fn())); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + adapter.dispose(); + await Promise.all(fillers.map(({ result }) => result.catch((error: unknown) => error))); + } + ); + + it('uses one exact independent ten-second deadline per enqueued operation', async () => { + vi.useFakeTimers(); + const adapter = createBrowserGoogletagAdapter({}); + const first = adapter.run(vi.fn()); + const firstResult = first.result.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(5_000); + const second = adapter.run(vi.fn()); + const secondResult = second.result.catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(4_999); + expect(first.status).toBe('pending'); + expect(second.status).toBe('pending'); + + await vi.advanceTimersByTimeAsync(1); + expect(first.status).toBe('timed_out'); + await expect(firstResult).resolves.toMatchObject({ code: 'external_ready_timeout' }); + expect(second.status).toBe('pending'); + + await vi.advanceTimersByTimeAsync(5_000); + expect(second.status).toBe('timed_out'); + await expect(secondResult).resolves.toMatchObject({ code: 'external_ready_timeout' }); + }); + + it('lets readiness immediately before the deadline win the operation latch', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const operation = adapter.run(() => 'ready'); + + await vi.advanceTimersByTimeAsync(9_999); + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + await vi.advanceTimersByTimeAsync(1); + + expect(operation.status).toBe('present'); + await expect(operation.result).resolves.toBe('ready'); + }); + + it('lets the first callback at the exact deadline win and keeps the loser inert', async () => { + vi.useFakeTimers(); + const ready = createReadyGoogletag(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + vi.setSystemTime(0); + const operation = adapter.run(() => 'ready'); + + vi.setSystemTime(10_000); + target.googletag = ready.googletag; + adapter.notifyReady(); + await vi.runOnlyPendingTimersAsync(); + + expect(operation.status).toBe('present'); + await expect(operation.result).resolves.toBe('ready'); + expect(ready.googletag.cmd.push).toHaveBeenCalledTimes(1); + }); + + it('lets timeout at or after the deadline win and ignores late readiness', async () => { + vi.useFakeTimers(); + const command = vi.fn(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const operation = adapter.run(command); + const result = operation.result.catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(10_000); + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + + expect(operation.status).toBe('timed_out'); + await expect(result).resolves.toMatchObject({ code: 'external_ready_timeout' }); + expect(command).not.toHaveBeenCalled(); + }); + + it('removes aborted and disposed operations immediately', async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const abortedCommand = vi.fn(); + const disposedCommand = vi.fn(); + const aborted = adapter.run(abortedCommand, { signal: controller.signal }); + const disposed = adapter.run(disposedCommand); + const abortedResult = aborted.result.catch((error: unknown) => error); + const disposedResult = disposed.result.catch((error: unknown) => error); + + controller.abort(); + disposed.dispose(); + const replacements = Array.from({ length: 64 }, () => adapter.run(() => undefined)); + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + + await expect(abortedResult).resolves.toMatchObject({ code: 'caller_aborted' }); + await expect(disposedResult).resolves.toMatchObject({ code: 'operation_disposed' }); + expect(abortedCommand).not.toHaveBeenCalled(); + expect(disposedCommand).not.toHaveBeenCalled(); + await expect(Promise.all(replacements.map(({ result }) => result))).resolves.toHaveLength(64); + }); + + it('disposes the adapter by removing every pending operation', async () => { + const command = vi.fn(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const operation = adapter.run(command); + const result = operation.result.catch((error: unknown) => error); + + adapter.dispose(); + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + + await expect(result).resolves.toMatchObject({ code: 'operation_disposed' }); + expect(command).not.toHaveBeenCalled(); + }); + + it('invalidates an entered command immediately when the adapter is disposed', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const operation = adapter.run((gpt) => { + adapter.dispose(); + gpt.display('must-not-display'); + }); + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(operation.status).toBe('present'); + expect(ready.display).not.toHaveBeenCalled(); + expect(ready.listeners.size).toBe(0); + }); + + it('throws when GPT inspection disposes the adapter before an operation is published', () => { + const ready = createReadyGoogletag(); + const holder: { adapter?: ReturnType } = {}; + const target = Object.defineProperty({}, 'googletag', { + get: () => { + holder.adapter?.dispose(); + return ready.googletag; + }, + }); + const adapter = createBrowserGoogletagAdapter(target); + holder.adapter = adapter; + const command = vi.fn(); + + expect(() => adapter.run(command)).toThrowError( + expect.objectContaining({ code: 'operation_disposed' }) + ); + expect(command).not.toHaveBeenCalled(); + expect(ready.commands).toHaveLength(0); + }); + + it('rejects without enqueueing when GPT inspection disposes a published operation', async () => { + const ready = createReadyGoogletag({ deferCommands: true }); + let reads = 0; + const holder: { adapter?: ReturnType } = {}; + const target = Object.defineProperty({}, 'googletag', { + get: () => { + reads += 1; + if (reads === 3) holder.adapter?.dispose(); + return ready.googletag; + }, + }); + const adapter = createBrowserGoogletagAdapter(target); + holder.adapter = adapter; + const command = vi.fn(); + const operation = adapter.run(command); + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(command).not.toHaveBeenCalled(); + expect(ready.commands).toHaveLength(0); + }); + + it('contains disposal reentrant from GPT member reads and external calls', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const staleSet = vi.fn(); + const slot = Object.defineProperty({}, 'setTargeting', { + get: () => { + adapter.dispose(); + return staleSet; + }, + }); + const memberOperation = adapter.run((gpt) => gpt.setTargeting(slot, 'key', 'value')); + + await expect(memberOperation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(staleSet).not.toHaveBeenCalled(); + + const externalReady = createReadyGoogletag(); + const externalAdapter = createBrowserGoogletagAdapter({ + googletag: externalReady.googletag, + }); + externalReady.display.mockImplementation(() => externalAdapter.dispose()); + const externalOperation = externalAdapter.run((gpt) => { + gpt.display('first'); + gpt.display('must-not-display'); + }); + + await expect(externalOperation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(externalReady.display).toHaveBeenCalledTimes(1); + }); + + it('does not enqueue after disposal reentrant from GPT configuration reads', async () => { + const ready = createReadyGoogletag({ deferCommands: true }); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisableInitialLoad = ready.pubads.disableInitialLoad; + ready.googletag.getConfig.mockImplementation(() => { + adapter.dispose(); + return { disableInitialLoad: false }; + }); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const command = vi.fn(); + const operation = adapter.run(command); + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(ready.commands).toHaveLength(0); + expect(command).not.toHaveBeenCalled(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisableInitialLoad); + + ready.googletag.getConfig.mockImplementation((key: string) => + key === 'disableInitialLoad' ? { disableInitialLoad: ready.initialLoad.disabled } : {} + ); + const laterAdapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const laterOperation = laterAdapter.run((gpt) => gpt.serviceState()); + ready.commands[0]?.(); + await laterOperation.result; + laterAdapter.dispose(); + + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisableInitialLoad); + }); + + it.each(['root', 'service'] as const)( + 'rolls back a GPT %s wrapper when post-install currentness inspection disposes', + async (wrapperKind) => { + const ready = createReadyGoogletag({ deferCommands: true }); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisableInitialLoad = ready.pubads.disableInitialLoad; + let wrappedReads = 0; + const holder: { adapter?: ReturnType } = {}; + const target = Object.defineProperty({}, 'googletag', { + get: () => { + const wrapped = + wrapperKind === 'root' + ? ready.googletag.setConfig !== nativeSetConfig + : ready.pubads.disableInitialLoad !== nativeDisableInitialLoad; + if (wrapped) { + wrappedReads += 1; + if (wrappedReads === 7) holder.adapter?.dispose(); + } + return ready.googletag; + }, + }); + const adapter = createBrowserGoogletagAdapter(target); + holder.adapter = adapter; + const operation = adapter.run(vi.fn()); + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisableInitialLoad); + expect(ready.commands).toHaveLength(0); + } + ); + + it('keeps abort ownership while a ready GPT command callback is deferred', async () => { + const deferred = createReadyGoogletag({ deferCommands: true }); + const controller = new AbortController(); + const command = vi.fn(); + const adapter = createBrowserGoogletagAdapter({ googletag: deferred.googletag }); + const operation = adapter.run(command, { signal: controller.signal }); + const result = operation.result.catch((error: unknown) => error); + + expect(operation.status).toBe('present'); + controller.abort(); + expect(() => deferred.commands[0]?.()).not.toThrow(); + + await expect(result).resolves.toMatchObject({ code: 'caller_aborted' }); + expect(command).not.toHaveBeenCalled(); + }); + + it('rejects a deferred command against a replaced GPT object and accepts later work', async () => { + const first = createReadyGoogletag({ deferCommands: true }); + const replacement = createReadyGoogletag(); + const target: { googletag?: unknown } = { googletag: first.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + const command = vi.fn(); + const operation = adapter.run(command); + const result = operation.result.catch((error: unknown) => error); + + target.googletag = replacement.googletag; + expect(() => first.commands[0]?.()).not.toThrow(); + + await expect(result).resolves.toMatchObject({ code: 'external_artifact_incompatible' }); + expect(operation.status).toBe('incompatible'); + expect(command).not.toHaveBeenCalled(); + await expect(adapter.run(() => 'replacement').result).resolves.toBe('replacement'); + }); + + it('rechecks GPT identity around hostile member reads and external calls', async () => { + const first = createReadyGoogletag(); + const replacement = createReadyGoogletag(); + const target: { googletag?: unknown } = { googletag: first.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + const staleSet = vi.fn(); + const slot = Object.defineProperty({}, 'setTargeting', { + get: () => { + target.googletag = replacement.googletag; + return staleSet; + }, + }); + const operation = adapter.run((gpt) => gpt.setTargeting(slot, 'key', 'value')); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(operation.status).toBe('incompatible'); + expect(staleSet).not.toHaveBeenCalled(); + }); + + it('makes an old GPT listener inert after whole-object replacement', async () => { + const first = createReadyGoogletag(); + const replacement = createReadyGoogletag(); + const target: { googletag?: unknown } = { googletag: first.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + const listener = vi.fn(); + let unsubscribe = (): void => undefined; + await adapter.run((gpt) => { + unsubscribe = gpt.subscribe('slotRequested', listener); + }).result; + const oldListener = [...(first.listeners.get('slotRequested') ?? [])][0]; + + target.googletag = replacement.googletag; + expect(() => oldListener?.({ slot: {} })).not.toThrow(); + unsubscribe(); + + expect(listener).not.toHaveBeenCalled(); + expect(first.pubads.removeEventListener).toHaveBeenCalledTimes(1); + }); + + it('rolls back an exact GPT listener when installation replaces the binding', async () => { + const first = createReadyGoogletag(); + const replacement = createReadyGoogletag(); + const target: { googletag?: unknown } = { googletag: first.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + first.pubads.addEventListener.mockImplementation((type, listener) => { + const registered = first.listeners.get(type) ?? new Set(); + registered.add(listener); + first.listeners.set(type, registered); + target.googletag = replacement.googletag; + }); + const operation = adapter.run((gpt) => gpt.subscribe('slotRequested', vi.fn())); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + const installed = first.pubads.addEventListener.mock.calls[0]?.[1]; + expect(first.pubads.removeEventListener).toHaveBeenCalledWith('slotRequested', installed); + expect(first.listeners.get('slotRequested')?.size).toBe(0); + }); + + it('rolls back a GPT listener when installation disposes and cleanup throws', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + ready.pubads.addEventListener.mockImplementation((type, listener) => { + const registered = ready.listeners.get(type) ?? new Set(); + registered.add(listener); + ready.listeners.set(type, registered); + adapter.dispose(); + }); + ready.pubads.removeEventListener.mockImplementation((type, listener) => { + ready.listeners.get(type)?.delete(listener); + throw new Error('cleanup failed'); + }); + const operation = adapter.run((gpt) => gpt.subscribe('slotRequested', vi.fn())); + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(ready.pubads.removeEventListener).toHaveBeenCalledTimes(1); + expect(ready.listeners.get('slotRequested')?.size).toBe(0); + }); + + it.each(['dispose', 'throw'] as const)( + 'rolls back GPT subscription ownership when effect registration must %s', + async (failure) => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const registryError = new Error('effect registry add failed'); + const originalDescriptor = Object.getOwnPropertyDescriptor(Set.prototype, 'add'); + const nativeAdd = Set.prototype.add; + const existingListeners = new Set<(event: unknown) => void>(); + const failedListeners = new Set<(event: unknown) => void>(); + const existingListener = vi.fn(); + const failedListener = vi.fn(); + ready.listeners.set('existing', existingListeners); + ready.listeners.set('failed', failedListeners); + let operation: ReturnType | undefined; + try { + operation = adapter.run((gpt) => { + gpt.subscribe('existing', existingListener); + Object.defineProperty(Set.prototype, 'add', { + configurable: true, + writable: true, + value: function (this: Set, value: unknown): Set { + if ( + typeof value === 'function' && + this !== existingListeners && + this !== failedListeners + ) { + if (failure === 'dispose') adapter.dispose(); + else throw registryError; + } + return Reflect.apply(nativeAdd, this, [value]) as Set; + }, + }); + return gpt.subscribe('failed', failedListener); + }); + } finally { + if (originalDescriptor) Object.defineProperty(Set.prototype, 'add', originalDescriptor); + } + + if (failure === 'dispose') { + await expect(operation?.result).rejects.toMatchObject({ code: 'operation_disposed' }); + } else { + await expect(operation?.result).rejects.toBe(registryError); + } + adapter.dispose(); + adapter.dispose(); + + expect(ready.listeners.get('existing')?.size).toBe(0); + expect(ready.listeners.get('failed')?.size).toBe(0); + expect(ready.pubads.removeEventListener).toHaveBeenCalledTimes(2); + } + ); + + it('rolls back a failed GPT command subscription without touching prior global effects', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const priorListener = vi.fn(); + const failedListener = vi.fn(); + const commandError = new Error('command failed'); + await adapter.run((gpt) => gpt.subscribe('prior', priorListener)).result; + + const operation = adapter.run((gpt) => { + gpt.subscribe('failed', failedListener); + throw commandError; + }); + + await expect(operation.result).rejects.toBe(commandError); + expect(ready.listeners.get('prior')?.size).toBe(1); + expect(ready.listeners.get('failed')?.size).toBe(0); + expect(() => [...(ready.listeners.get('prior') ?? [])][0]?.({})).not.toThrow(); + expect(priorListener).toHaveBeenCalledTimes(1); + expect(failedListener).not.toHaveBeenCalled(); + + adapter.dispose(); + expect(ready.listeners.get('prior')?.size).toBe(0); + expect(ready.pubads.removeEventListener).toHaveBeenCalledTimes(2); + }); + + it('promotes fulfilled GPT command subscriptions and rolls back rejected ones', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const rejection = new Error('async command failed'); + const rejected = adapter.run((gpt) => { + gpt.subscribe('rejected', vi.fn()); + return Promise.reject(rejection); + }); + + await expect(rejected.result).rejects.toBe(rejection); + expect(ready.listeners.get('rejected')?.size).toBe(0); + + const fulfilled = adapter.run((gpt) => { + gpt.subscribe('fulfilled', vi.fn()); + return Promise.resolve('complete'); + }); + await expect(fulfilled.result).resolves.toBe('complete'); + expect(ready.listeners.get('fulfilled')?.size).toBe(1); + + adapter.dispose(); + expect(ready.listeners.get('fulfilled')?.size).toBe(0); + }); + + it.each(['dispose', 'replacement'] as const)( + 'rolls back a provisional GPT subscription after async %s', + async (failure) => { + const first = createReadyGoogletag(); + const replacement = createReadyGoogletag(); + const target: { googletag?: unknown } = { googletag: first.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + let resolveCommand!: (value: string) => void; + const commandResult = new Promise((resolve) => { + resolveCommand = resolve; + }); + const operation = adapter.run((gpt) => { + gpt.subscribe('provisional', vi.fn()); + return commandResult; + }); + + if (failure === 'dispose') adapter.dispose(); + else target.googletag = replacement.googletag; + resolveCommand('late-success'); + + await expect(operation.result).rejects.toMatchObject({ + code: failure === 'dispose' ? 'operation_disposed' : 'external_artifact_incompatible', + }); + expect(first.listeners.get('provisional')?.size).toBe(0); + } + ); + + it('contains command-queue and command-callback throws', async () => { + const pushError = new Error('push failed'); + const callbackError = new Error('callback failed'); + const throwingPush = { + apiReady: true, + pubadsReady: true, + cmd: { + push: () => { + throw pushError; + }, + }, + display: vi.fn(), + pubads: () => createReadyGoogletag().pubads, + }; + const pushAdapter = createBrowserGoogletagAdapter({ googletag: throwingPush }); + + let pushOperation: ReturnType | undefined; + expect(() => { + pushOperation = pushAdapter.run(() => undefined); + }).not.toThrow(); + await expect(pushOperation?.result).rejects.toBe(pushError); + + const deferred = createReadyGoogletag({ deferCommands: true }); + const callbackAdapter = createBrowserGoogletagAdapter({ googletag: deferred.googletag }); + const callbackOperation = callbackAdapter.run(() => { + throw callbackError; + }); + expect(() => deferred.commands[0]?.()).not.toThrow(); + await expect(callbackOperation.result).rejects.toBe(callbackError); + }); + + it('owns GPT subscriptions, refresh, targeting, and service inspection behind the facade', async () => { + const ready = createReadyGoogletag({ initialLoadDisabled: true }); + const targeting = new Map(); + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) targeting.clear(); + else targeting.delete(key); + }), + getTargeting: vi.fn((key: string) => targeting.get(key) ?? []), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + targeting.set(key, typeof value === 'string' ? [value] : [...value]); + }), + }; + ready.pubads.getSlots.mockReturnValue([slot]); + const listener = vi.fn(() => { + throw new Error('publisher callback failed'); + }); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const operation = adapter.run((gpt) => { + const unsubscribe = gpt.subscribe('slotRequested', listener); + gpt.setTargeting(slot, 'hb_adid', 'reservation'); + expect(gpt.getTargeting(slot, 'hb_adid')).toEqual(['reservation']); + gpt.refresh([slot], { changeCorrelator: false }); + expect(gpt.slots()).toEqual([slot]); + expect(Object.isFrozen(gpt.slots())).toBe(true); + expect(gpt.serviceState()).toEqual({ + apiReady: true, + initialLoadDisabled: true, + pubadsReady: true, + }); + const installed = [...(ready.listeners.get('slotRequested') ?? [])][0]; + expect(() => installed?.({ slot })).not.toThrow(); + unsubscribe(); + gpt.clearTargeting(slot, 'hb_adid'); + }); + + await expect(operation.result).resolves.toBeUndefined(); + expect(listener).toHaveBeenCalledWith({ slot }); + expect(ready.pubads.refresh).toHaveBeenCalledWith([slot], { changeCorrelator: false }); + expect(ready.pubads.removeEventListener).toHaveBeenCalledTimes(1); + expect(targeting.has('hb_adid')).toBe(false); + }); + + it('tracks native GPT initial-load configuration without duplicate wrappers', async () => { + const ready = createReadyGoogletag({ initialLoadDisabled: true }); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisableInitialLoad = ready.pubads.disableInitialLoad; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + + await expect(adapter.run((gpt) => gpt.serviceState().initialLoadDisabled).result).resolves.toBe( + true + ); + const wrappedSetConfig = ready.googletag.setConfig; + const wrappedDisableInitialLoad = ready.pubads.disableInitialLoad; + expect(wrappedSetConfig).not.toBe(nativeSetConfig); + expect(wrappedDisableInitialLoad).not.toBe(nativeDisableInitialLoad); + expect(ready.googletag.getConfig).toHaveBeenCalledWith('disableInitialLoad'); + + await adapter.run((gpt) => gpt.serviceState()).result; + expect(ready.googletag.setConfig).toBe(wrappedSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(wrappedDisableInitialLoad); + + expect(ready.googletag.setConfig({ disableInitialLoad: false })).toBe('config-result'); + await expect(adapter.run((gpt) => gpt.serviceState().initialLoadDisabled).result).resolves.toBe( + false + ); + expect(ready.pubads.disableInitialLoad()).toBe('legacy-result'); + await expect(adapter.run((gpt) => gpt.serviceState().initialLoadDisabled).result).resolves.toBe( + true + ); + + adapter.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisableInitialLoad); + expect(Reflect.ownKeys(ready.googletag).some((key) => String(key).startsWith('__ts'))).toBe( + false + ); + }); + + it('preserves GPT configuration calls and falls back only when getConfig is unavailable', async () => { + const ready = createReadyGoogletag(); + const binding = ready.googletag as unknown as Record; + const nativeSetConfig = vi.fn(function ( + this: unknown, + config: { readonly disableInitialLoad?: boolean | null }, + marker: string + ) { + ready.initialLoad.disabled = config.disableInitialLoad === true; + return { marker, receiver: this }; + }); + binding['setConfig'] = nativeSetConfig; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await adapter.run((gpt) => gpt.serviceState()).result; + const receiver = { publisher: true }; + const config = { disableInitialLoad: true }; + + const wrappedSetConfig = binding['setConfig'] as (...arguments_: unknown[]) => unknown; + const returned = Reflect.apply(wrappedSetConfig, receiver, [config, 'exact']); + expect(nativeSetConfig).toHaveBeenCalledWith(config, 'exact'); + expect(returned).toEqual({ marker: 'exact', receiver }); + await expect(adapter.run((gpt) => gpt.serviceState().initialLoadDisabled).result).resolves.toBe( + true + ); + + binding['getConfig'] = undefined; + Reflect.apply(wrappedSetConfig, receiver, [{ disableInitialLoad: false }, 'fallback']); + await expect(adapter.run((gpt) => gpt.serviceState().initialLoadDisabled).result).resolves.toBe( + false + ); + }); + + it('shares one GPT wrapper across adapter instances until the last owner disposes', async () => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisableInitialLoad = ready.pubads.disableInitialLoad; + const first = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const second = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await first.run((gpt) => gpt.serviceState()).result; + const sharedSetConfig = ready.googletag.setConfig; + const sharedDisableInitialLoad = ready.pubads.disableInitialLoad; + + await second.run((gpt) => gpt.serviceState()).result; + expect(ready.googletag.setConfig).toBe(sharedSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(sharedDisableInitialLoad); + + first.dispose(); + expect(ready.googletag.setConfig).toBe(sharedSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(sharedDisableInitialLoad); + ready.googletag.setConfig({ disableInitialLoad: true }); + await expect(second.run((gpt) => gpt.serviceState().initialLoadDisabled).result).resolves.toBe( + true + ); + + second.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisableInitialLoad); + }); + + it('releases historical GPT initial-load ownership across A to B to C', async () => { + const first = createReadyGoogletag(); + const second = createReadyGoogletag(); + const third = createReadyGoogletag(); + const firstNativeSetConfig = first.googletag.setConfig; + const firstNativeDisable = first.pubads.disableInitialLoad; + const secondPublisherSetConfig = vi.fn(); + const secondPublisherDisable = vi.fn(); + const thirdNativeSetConfig = third.googletag.setConfig; + const thirdNativeDisable = third.pubads.disableInitialLoad; + const target: { googletag?: unknown } = { googletag: first.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + + await adapter.run((gpt) => gpt.serviceState()).result; + expect(first.googletag.setConfig).not.toBe(firstNativeSetConfig); + target.googletag = second.googletag; + await adapter.run((gpt) => gpt.serviceState()).result; + expect(first.googletag.setConfig).toBe(firstNativeSetConfig); + expect(first.pubads.disableInitialLoad).toBe(firstNativeDisable); + + second.googletag.setConfig = secondPublisherSetConfig; + second.pubads.disableInitialLoad = secondPublisherDisable; + target.googletag = third.googletag; + await adapter.run((gpt) => gpt.serviceState()).result; + expect(second.googletag.setConfig).toBe(secondPublisherSetConfig); + expect(second.pubads.disableInitialLoad).toBe(secondPublisherDisable); + expect(third.googletag.setConfig).not.toBe(thirdNativeSetConfig); + expect(third.pubads.disableInitialLoad).not.toBe(thirdNativeDisable); + + adapter.dispose(); + expect(third.googletag.setConfig).toBe(thirdNativeSetConfig); + expect(third.pubads.disableInitialLoad).toBe(thirdNativeDisable); + }); + + it('preserves shared GPT initial-load ownership when one adapter changes bindings', async () => { + const first = createReadyGoogletag(); + const second = createReadyGoogletag(); + const firstNativeSetConfig = first.googletag.setConfig; + const firstNativeDisable = first.pubads.disableInitialLoad; + const secondNativeSetConfig = second.googletag.setConfig; + const firstTarget: { googletag?: unknown } = { googletag: first.googletag }; + const secondTarget: { googletag?: unknown } = { googletag: first.googletag }; + const firstAdapter = createBrowserGoogletagAdapter(firstTarget); + const secondAdapter = createBrowserGoogletagAdapter(secondTarget); + await firstAdapter.run((gpt) => gpt.serviceState()).result; + await secondAdapter.run((gpt) => gpt.serviceState()).result; + const sharedSetConfig = first.googletag.setConfig; + + firstTarget.googletag = second.googletag; + await firstAdapter.run((gpt) => gpt.serviceState()).result; + expect(first.googletag.setConfig).toBe(sharedSetConfig); + expect(first.pubads.disableInitialLoad).not.toBe(firstNativeDisable); + expect(second.googletag.setConfig).not.toBe(secondNativeSetConfig); + + secondAdapter.dispose(); + expect(first.googletag.setConfig).toBe(firstNativeSetConfig); + expect(first.pubads.disableInitialLoad).toBe(firstNativeDisable); + expect(second.googletag.setConfig).not.toBe(secondNativeSetConfig); + + firstAdapter.dispose(); + expect(second.googletag.setConfig).toBe(secondNativeSetConfig); + }); + + it('does not overwrite publisher GPT method replacements during restoration', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await adapter.run((gpt) => gpt.serviceState()).result; + const publisherSetConfig = vi.fn(); + const publisherDisableInitialLoad = vi.fn(); + ready.googletag.setConfig = publisherSetConfig; + ready.pubads.disableInitialLoad = publisherDisableInitialLoad; + + adapter.dispose(); + + expect(ready.googletag.setConfig).toBe(publisherSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(publisherDisableInitialLoad); + }); +}); diff --git a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts new file mode 100644 index 000000000..739546891 --- /dev/null +++ b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts @@ -0,0 +1,673 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + PROTOCOL_MESSAGE_SCHEMAS_V1, + TSJS_MESSAGE_PROTOCOL_V1, + createBrowserMessagingAdapter, +} from '../../src/adapters/messaging'; + +function createTarget() { + return { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; +} + +function createPort() { + const listeners = new Set<(event: unknown) => void>(); + const messageErrorListeners = new Set<(event: unknown) => void>(); + return { + addEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + (type === 'messageerror' ? messageErrorListeners : listeners).add(listener); + }), + close: vi.fn(), + listeners, + messageErrorListeners, + postMessage: vi.fn(), + removeEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + (type === 'messageerror' ? messageErrorListeners : listeners).delete(listener); + }), + start: vi.fn(), + }; +} + +function createApsRenderer() { + return { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + }; +} + +describe('browser messaging adapter', () => { + it('centralizes every protocol literal and exact message shape as frozen data', () => { + expect(TSJS_MESSAGE_PROTOCOL_V1).toEqual({ + version: 1, + rendererVersion: '3', + message: { + prebidRequest: 'Prebid Request', + prebidResponse: 'Prebid Response', + ownerRegister: 'TS Render Owner Register', + ownerRegistered: 'TS Render Owner Registered', + ownerRefused: 'TS Render Owner Refused', + apsStart: 'TS APS Start', + admStart: 'TS ADM Start', + ownerInserted: 'TS Owner Inserted', + ownerSettled: 'TS Owner Settled', + admLoaded: 'TS ADM Loaded', + admFailed: 'TS ADM Failed', + apsDocumentAccepted: 'TS APS Document Accepted', + apsRunnerLoaded: 'TS APS Runner Loaded', + apsRenderCompleted: 'TS APS Render Completed', + apsRenderFailed: 'TS APS Render Failed', + }, + status: { ready: 'ready', refused: 'refused' }, + kind: { aps: 'aps', adm: 'adm' }, + outcome: { accepted: 'accepted', failed: 'failed', cancelled: 'cancelled' }, + runnerFailure: { + descriptorInvalid: 'descriptor_invalid', + runnerNoLoad: 'runner_no_load', + runnerFailed: 'runner_failed', + }, + cancellation: { + callerAborted: 'caller_aborted', + superseded: 'superseded', + navigationDisposed: 'navigation_disposed', + }, + }); + expect(Object.isFrozen(TSJS_MESSAGE_PROTOCOL_V1)).toBe(true); + expect(Object.isFrozen(TSJS_MESSAGE_PROTOCOL_V1.message)).toBe(true); + expect(Object.isFrozen(PROTOCOL_MESSAGE_SCHEMAS_V1)).toBe(true); + expect(Object.isFrozen(PROTOCOL_MESSAGE_SCHEMAS_V1.apsStart.keys)).toBe(true); + }); + + it('parses global JSON and structured messages through exact descriptor-safe schemas', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + expect( + adapter.parseProtocolMessage( + 'prebidRequest', + JSON.stringify({ + message: 'Prebid Request', + adId: 'r1_1234567890123456789012', + adServerDomain: 'ads.example.com', + }) + ) + ).toEqual({ + message: 'Prebid Request', + adId: 'r1_1234567890123456789012', + adServerDomain: 'ads.example.com', + }); + expect( + adapter.parseProtocolMessage('ownerInserted', { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }) + ).toEqual({ + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }); + + for (const candidate of [ + { message: 'TS Owner Inserted', version: 2, lifecycleTicket: 't1_abcdefghijklmnopqrstuv' }, + { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + extra: true, + }, + { message: 'wrong', version: 1, lifecycleTicket: 't1_abcdefghijklmnopqrstuv' }, + Object.assign(Object.create({ inherited: true }), { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }), + ]) { + expect(adapter.parseProtocolMessage('ownerInserted', candidate)).toBeUndefined(); + } + }); + + it('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('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 first = createPort(); + const second = createPort(); + + const zero = adapter.extractTransferredPorts({ ports: [] }, 0); + const one = adapter.extractTransferredPorts({ ports: [first] }, 1); + const two = adapter.extractTransferredPorts({ ports: [first, second] }, 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('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('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 both port listeners when messageerror installation or start fails', () => { + for (const failure of ['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 === 'messageerror' && type === 'messageerror') { + throw new Error('messageerror 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)); + expect(raw.removeEventListener).toHaveBeenCalledWith('messageerror', expect.any(Function)); + expect(raw.removeEventListener).toHaveBeenCalledTimes(2); + } + }); + + 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(() => 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())).not.toThrow(); + }); + + 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()).not.toThrow(); + expect(() => dispose()).not.toThrow(); + 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..052500a8b --- /dev/null +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -0,0 +1,1033 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createBrowserPrebidAdapter } from '../../src/adapters/prebid'; + +type Command = () => void; + +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(), + addBidResponse: vi.fn(), + 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(), + que: { + push: vi.fn((command: Command): number => { + if (options.deferCommands) commands.push(command); + else command(); + return commands.length; + }), + }, + renderAd: vi.fn(), + requestBids: 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 adapter = createBrowserPrebidAdapter({ pbjs: ready.pbjs }); + 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.addBidResponse('slot-a', { adId: 'bid-a' }); + prebid.requestBids({ adUnitCodes: ['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.addBidResponse).toHaveBeenCalledWith('slot-a', { adId: 'bid-a' }); + expect(ready.pbjs.requestBids).toHaveBeenCalledTimes(1); + 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('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', + 'addBidResponse', + 'getHighestCpmBids', + 'offEvent', + 'onEvent', + 'processQueue', + 'renderAd', + 'requestBids', + ] 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('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('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('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.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('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); + }); +}); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 591adcc0e..86a690b50 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -1,8 +1,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { GoogletagAdapter } from '../../src/adapters/googletag'; -import type { CaptureMessageListener, MessagingAdapter } from '../../src/adapters/messaging'; -import type { PrebidAdapter } from '../../src/adapters/prebid'; +import { + createNoopGoogletagAdapter, + type GoogletagAdapter, + type GoogletagBindingStatus, +} from '../../src/adapters/googletag'; +import { + createNoopMessagingAdapter, + type CaptureMessageListener, + type MessagingAdapter, +} from '../../src/adapters/messaging'; +import { + createNoopPrebidAdapter, + type PrebidAdapter, + type PrebidBindingStatus, +} from '../../src/adapters/prebid'; import { createBrowserComposition, createNoopBrowserComposition, @@ -21,6 +33,24 @@ function createTarget() { }; } +function fakeGoogletagAdapter( + bindingStatus: () => GoogletagBindingStatus = () => 'pending' +): GoogletagAdapter { + return Object.freeze({ ...createNoopGoogletagAdapter(), bindingStatus }); +} + +function fakePrebidAdapter( + bindingStatus: () => PrebidBindingStatus = () => 'pending' +): PrebidAdapter { + return Object.freeze({ ...createNoopPrebidAdapter(), bindingStatus }); +} + +function fakeMessagingAdapter( + installCaptureListener: MessagingAdapter['installCaptureListener'] = () => vi.fn() +): MessagingAdapter { + return Object.freeze({ ...createNoopMessagingAdapter(), installCaptureListener }); +} + describe('browser composition', () => { afterEach(() => vi.useRealTimers()); @@ -34,8 +64,8 @@ describe('browser composition', () => { target.googletag = {}; target.pbjs = {}; - expect(composition.adapters.googletag.bindingStatus()).toBe('present'); - expect(composition.adapters.prebid.bindingStatus()).toBe('present'); + expect(composition.adapters.googletag.bindingStatus()).toBe('incompatible'); + expect(composition.adapters.prebid.bindingStatus()).toBe('incompatible'); target.googletag = 1; target.pbjs = 'not-prebid'; @@ -43,6 +73,42 @@ describe('browser composition', () => { expect(composition.adapters.prebid.bindingStatus()).toBe('incompatible'); }); + 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 }); @@ -51,24 +117,20 @@ describe('browser composition', () => { const dispose = composition.adapters.messaging.installCaptureListener(listener); expect(target.addEventListener).toHaveBeenCalledTimes(1); - expect(target.addEventListener).toHaveBeenCalledWith('message', listener, true); + 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', listener, true); + expect(target.removeEventListener).toHaveBeenCalledWith('message', installed, true); }); it('uses exact injected fakes without constructing concrete adapters', () => { - const googletag: GoogletagAdapter = { - bindingStatus: () => 'present', - }; - const prebid: PrebidAdapter = { - bindingStatus: () => 'incompatible', - }; - const messaging: MessagingAdapter = { - installCaptureListener: () => vi.fn(), - }; + const googletag = fakeGoogletagAdapter(() => 'present'); + const prebid = fakePrebidAdapter(() => 'incompatible'); + const messaging = fakeMessagingAdapter(); const composition = createBrowserComposition({ adapters: { googletag, messaging, prebid }, @@ -119,9 +181,9 @@ describe('browser composition', () => { }, { adapters: { - googletag: { bindingStatus: () => 'pending' }, - prebid: { bindingStatus: () => 'pending' }, - messaging: { installCaptureListener: () => vi.fn() }, + googletag: fakeGoogletagAdapter(), + prebid: fakePrebidAdapter(), + messaging: fakeMessagingAdapter(), }, coreActivations: { bridgeRecognizer: ({ onDispose }, adapters) => { @@ -162,6 +224,12 @@ describe('browser composition', () => { 'dispose-gpt', 'dispose-bridge', ]); + 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); }); @@ -196,9 +264,9 @@ describe('browser composition', () => { }, { adapters: { - googletag: { bindingStatus: () => 'pending' }, - prebid: { bindingStatus: () => 'pending' }, - messaging: { installCaptureListener: () => vi.fn() }, + googletag: fakeGoogletagAdapter(), + prebid: fakePrebidAdapter(), + messaging: fakeMessagingAdapter(), }, coreActivations: { bridgeRecognizer: vi.fn(), @@ -471,9 +539,9 @@ describe('browser composition', () => { }, { adapters: { - googletag: { bindingStatus: adapterActivation }, - prebid: { bindingStatus: adapterActivation }, - messaging: { installCaptureListener: listenerActivation }, + googletag: fakeGoogletagAdapter(adapterActivation), + prebid: fakePrebidAdapter(adapterActivation), + messaging: fakeMessagingAdapter(listenerActivation), }, coreActivations: { bridgeRecognizer: timerActivation, From 46d9697b89cd1fb5b19576bbb5698c8bf42b1f2e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:44:42 -0700 Subject: [PATCH 028/194] Harden adapter queue admission and abort handshake --- .../lib/src/adapters/googletag.ts | 416 ++++++-- .../lib/src/adapters/messaging.ts | 36 +- .../lib/src/adapters/prebid.ts | 169 +++- .../lib/test/adapters/googletag.test.ts | 906 ++++++++++++++++++ .../lib/test/adapters/messaging.test.ts | 201 +++- .../lib/test/adapters/prebid.test.ts | 462 +++++++++ 6 files changed, 2053 insertions(+), 137 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index b71c8694b..9eb238869 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -99,6 +99,7 @@ interface AbortRegistration { interface PendingOperation { state: GoogletagOperationStatus; settled: boolean; + pendingReservation: boolean; timeout: ReturnType | undefined; readonly command: (googletag: Readonly) => T; readonly resolve: (value: T | PromiseLike) => void; @@ -117,6 +118,41 @@ interface SharedInitialLoadTracker { } 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 weakMapDeleteIntrinsic = WeakMap.prototype.delete; +const weakMapGetIntrinsic = WeakMap.prototype.get; +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 weakMapValue(map: WeakMap, key: K): V | undefined { + return Reflect.apply(weakMapGetIntrinsic, map, [key]) as V | undefined; +} + +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 { @@ -324,21 +360,36 @@ export function createBrowserGoogletagAdapter( const pending: PendingOperation[] = []; const live = new Set>(); const effects = new Set<() => void>(); - const armedBindings = new WeakSet(); + let armedBindings = new WeakSet(); const initialLoadReleases = new Map void>(); const initialLoadOwner = Object.freeze({}); + let pendingReservations = 0; let disposed = false; const registerAdapterEffect = (disposeEffect: () => void): void => { - if (disposed) { + const rollback = (): void => { + try { + deleteSetValue(effects, disposeEffect); + } catch { + // A hostile registry cannot retain the effect being rolled back. + } try { disposeEffect(); } catch { - // Reentrant disposal keeps newly-created effects from escaping the adapter. + // Cleanup cannot replace the publication failure or escape disposal. } + }; + if (disposed) { + rollback(); return; } - effects.add(disposeEffect); + try { + effects.add(disposeEffect); + } catch (error) { + rollback(); + throw error; + } + if (disposed) rollback(); }; const replaceMethod = ( @@ -430,25 +481,45 @@ export function createBrowserGoogletagAdapter( }; const releaseInitialLoadBinding = (binding: object): void => { - const release = initialLoadReleases.get(binding); + const release = mapValue(initialLoadReleases, binding); if (!release) return; - initialLoadReleases.delete(binding); try { - effects.delete(release); - } catch { - // A hostile registry cannot retain adapter ownership of an old binding. - } - try { - release(); - } catch { - // One historical binding cannot interrupt release of later bindings. + 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 [...initialLoadReleases.keys()]) { - if (binding !== current) releaseInitialLoadBinding(binding); + 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 = ( @@ -462,7 +533,7 @@ export function createBrowserGoogletagAdapter( }; if (!expectedCurrent()) return undefined; - let tracker = sharedInitialLoadTrackers.get(expected.binding); + let tracker = weakMapValue(sharedInitialLoadTrackers, expected.binding); if (!tracker) { tracker = { disabled: false, @@ -471,13 +542,27 @@ export function createBrowserGoogletagAdapter( restorers: new Set<() => void>(), services: new WeakMap void>(), }; - sharedInitialLoadTrackers.set(expected.binding, tracker); + 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 || - sharedInitialLoadTrackers.get(expected.binding) !== tracker || - !tracker.owners.has(initialLoadOwner) + weakMapValue(sharedInitialLoadTrackers, expected.binding) !== tracker || + !ownsInitialLoad() ) { return false; } @@ -485,43 +570,84 @@ export function createBrowserGoogletagAdapter( return ( !disposed && current && - sharedInitialLoadTrackers.get(expected.binding) === tracker && - tracker.owners.has(initialLoadOwner) + weakMapValue(sharedInitialLoadTrackers, expected.binding) === tracker && + ownsInitialLoad() ); }; let adoptedHere = false; - if (!initialLoadReleases.has(expected.binding)) { + 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 && - sharedInitialLoadTrackers.get(expected.binding) === tracker + weakMapValue(sharedInitialLoadTrackers, expected.binding) === tracker ) { - sharedInitialLoadTrackers.delete(expected.binding); + deleteWeakMapValue(sharedInitialLoadTrackers, expected.binding); } return undefined; } - tracker.owners.add(initialLoadOwner); + 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 => { - if (initialLoadReleases.get(expected.binding) === release) { - initialLoadReleases.delete(expected.binding); - } - if (!adoptedTracker.owners.delete(initialLoadOwner) || adoptedTracker.owners.size > 0) { - return; - } - if (sharedInitialLoadTrackers.get(expected.binding) === adoptedTracker) { - sharedInitialLoadTrackers.delete(expected.binding); - } - for (const restore of [...adoptedTracker.restorers].reverse()) { - try { - restore(); - } catch { - // One restoration cannot interrupt cleanup of the shared tracker. + 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. + } + } } } - adoptedTracker.restorers.clear(); }; - initialLoadReleases.set(expected.binding, release); + 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; } @@ -553,12 +679,21 @@ export function createBrowserGoogletagAdapter( const cleanup = (): void => { if (!active) return; active = false; - tracker!.restorers.delete(cleanup); - tracker!.rootWrapped = false; - restore(); + try { + deleteSetValue(tracker!.restorers, cleanup); + } finally { + tracker!.rootWrapped = false; + restore(); + } }; tracker.rootWrapped = true; - tracker.restorers.add(cleanup); + try { + tracker.restorers.add(cleanup); + } catch (error) { + cleanup(); + rollback(); + throw error; + } installedHere.push(cleanup); } if (!trackingCurrent()) return rollback(); @@ -566,7 +701,11 @@ export function createBrowserGoogletagAdapter( } const trackService = (service: object): boolean => { - if (tracker!.services.has(service)) return true; + try { + if (tracker!.services.has(service)) return true; + } catch { + return false; + } if (!trackingCurrent()) return false; const originalDisable = safeMember(service, 'disableInitialLoad'); if (!trackingCurrent()) return false; @@ -582,12 +721,35 @@ export function createBrowserGoogletagAdapter( const cleanup = (): void => { if (!active) return; active = false; - tracker!.restorers.delete(cleanup); - tracker!.services.delete(service); - restore(); + try { + deleteSetValue(tracker!.restorers, cleanup); + } finally { + try { + if (weakMapValue(tracker!.services, service) === cleanup) { + deleteWeakMapValue(tracker!.services, service); + } + } finally { + restore(); + } + } }; - tracker!.services.set(service, cleanup); - tracker!.restorers.add(cleanup); + 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; @@ -598,15 +760,16 @@ export function createBrowserGoogletagAdapter( if (!trackService(knownService)) return rollback(); } else { if (!trackingCurrent()) return rollback(); + let service: unknown; try { - const service = Reflect.apply(expected.pubads, expected.binding, []); - if (!trackingCurrent()) return rollback(); - if ((typeof service === 'object' && service !== null) || typeof service === 'function') { - if (!trackService(service as object)) return rollback(); - } + 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; @@ -660,12 +823,22 @@ export function createBrowserGoogletagAdapter( 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 => { - if (operation.timeout !== undefined) { - clearTimeout(operation.timeout); - operation.timeout = undefined; + try { + if (operation.timeout !== undefined) { + clearTimeout(operation.timeout); + operation.timeout = undefined; + } + removePending(operation); + } finally { + releasePendingReservation(operation); } - removePending(operation); }; const detachAbort = (operation: PendingOperation): void => { @@ -691,7 +864,7 @@ export function createBrowserGoogletagAdapter( try { detachAbort(operation); } finally { - live.delete(operation); + deleteSetValue(live, operation); } } }; @@ -735,12 +908,6 @@ export function createBrowserGoogletagAdapter( } operation.state = 'present'; clearReadiness(operation); - ensureInitialLoadTracking(binding); - if (disposed) { - fail(operation, 'operation_disposed'); - return; - } - if (operation.settled) return; const isDispatchCurrent = (): boolean => { if (disposed || operation.settled) return false; const current = sameBinding(binding); @@ -752,7 +919,7 @@ export function createBrowserGoogletagAdapter( const release = (): void => { if (promoted) { try { - effects.delete(release); + deleteSetValue(effects, release); } catch { // A hostile registry cannot prevent exact external cleanup. } @@ -857,6 +1024,17 @@ export function createBrowserGoogletagAdapter( } ); 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'); @@ -945,20 +1123,34 @@ export function createBrowserGoogletagAdapter( const armNotification = (): void => { const current = currentBinding(); if (disposed) return; - if ( - current.status !== 'pending' || - !current.binding || - !current.commandQueue || - armedBindings.has(current.binding) - ) { + 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; - armedBindings.add(current.binding); try { - queueCommand(current.commandQueue, () => notifyReady(current.binding)); + 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 { - // A later script-owned notification or operation may observe a replacement. + notificationActive = false; + rollbackNotificationArming(current.binding); } }; @@ -969,8 +1161,11 @@ export function createBrowserGoogletagAdapter( if (disposed) throw new GoogletagAdapterError('operation_disposed'); const current = currentBinding(); if (disposed) throw new GoogletagAdapterError('operation_disposed'); - if (current.status === 'pending' && pending.length >= MAX_PENDING_OPERATIONS) { - throw new GoogletagAdapterError('external_queue_full'); + if (current.status === 'pending') { + if (pendingReservations >= MAX_PENDING_OPERATIONS) { + throw new GoogletagAdapterError('external_queue_full'); + } + pendingReservations += 1; } let resolve!: (value: T | PromiseLike) => void; @@ -982,6 +1177,7 @@ export function createBrowserGoogletagAdapter( const operation: PendingOperation = { state: current.status, settled: false, + pendingReservation: current.status === 'pending', timeout: undefined, command, resolve, @@ -990,7 +1186,6 @@ export function createBrowserGoogletagAdapter( readinessBinding: current.status === 'pending' ? current.binding : undefined, provisionalEffects: [], }; - live.add(operation as PendingOperation); const handle = Object.freeze({ get status(): GoogletagOperationStatus { return operation.state; @@ -999,8 +1194,19 @@ export function createBrowserGoogletagAdapter( 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.push(operation as PendingOperation); + pending[pending.length] = operation as PendingOperation; operation.timeout = setTimeout( () => fail(operation as PendingOperation, 'external_ready_timeout'), EXTERNAL_READY_TIMEOUT_MS @@ -1088,6 +1294,22 @@ export function createBrowserGoogletagAdapter( 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') { @@ -1110,20 +1332,22 @@ export function createBrowserGoogletagAdapter( if (disposed) return; disposed = true; for (const operation of [...live]) fail(operation, 'operation_disposed'); - for (const binding of [...initialLoadReleases.keys()]) { - releaseInitialLoadBinding(binding); - } - initialLoadReleases.clear(); - for (const disposeEffect of [...effects]) { - try { - effects.delete(disposeEffect); - } catch { - // A hostile registry cannot interrupt cleanup of remaining effects. - } - try { - disposeEffect(); - } catch { - // One cleanup cannot interrupt the remaining adapter disposers. + 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. + } } } }, diff --git a/crates/trusted-server-js/lib/src/adapters/messaging.ts b/crates/trusted-server-js/lib/src/adapters/messaging.ts index b31a5e001..4f3627db6 100644 --- a/crates/trusted-server-js/lib/src/adapters/messaging.ts +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -1,4 +1,9 @@ const MAX_GLOBAL_MESSAGE_BYTES = 4_096; +const setDeleteIntrinsic = Set.prototype.delete; + +function deleteSetValue(set: Set, value: T): boolean { + return Reflect.apply(setDeleteIntrinsic, set, [value]) as boolean; +} /** Every protocol literal shared by the §4.2–§4.5 message channels. */ export const TSJS_MESSAGE_PROTOCOL_V1 = Object.freeze({ @@ -811,8 +816,11 @@ function wrapPort(raw: RawPort): MessagingPort { const dispose = (): void => { if (!active) return; active = false; - listeners.delete(dispose); - if (!setupInProgress) rollback(); + try { + deleteSetValue(listeners, dispose); + } finally { + if (!setupInProgress) rollback(); + } }; const stopClosedSetup = (): boolean => { if (!closed && active) return false; @@ -820,7 +828,20 @@ function wrapPort(raw: RawPort): MessagingPort { rollback(); return true; }; - listeners.add(dispose); + 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]); @@ -835,8 +856,13 @@ function wrapPort(raw: RawPort): MessagingPort { } catch { setupInProgress = false; active = false; - listeners.delete(dispose); - rollback(); + try { + deleteSetValue(listeners, dispose); + } catch { + // Failed bookkeeping cannot interrupt exact listener rollback. + } finally { + rollback(); + } return dispose; } setupInProgress = false; diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index b29961b0e..07e87ac50 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -3,6 +3,16 @@ 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'; @@ -120,6 +130,7 @@ interface AbortRegistration { interface PendingOperation { state: PrebidOperationStatus; settled: boolean; + pendingReservation: boolean; timeout: ReturnType | undefined; readonly command: (prebid: Readonly) => T; readonly resolve: (value: T | PromiseLike) => void; @@ -452,24 +463,57 @@ export function createBrowserPrebidAdapter( const pending: PendingOperation[] = []; const live = new Set>(); const effects = new Set<() => void>(); - const armedBindings = new WeakSet(); - const diagnosedBindings = new WeakSet(); + 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') { - const shouldDiagnose = inspected.binding - ? !diagnosedBindings.has(inspected.binding) - : !diagnosedUnbound; - if (shouldDiagnose) { - if (inspected.binding) diagnosedBindings.add(inspected.binding); - else diagnosedUnbound = true; + let shouldDiagnose = !diagnosedUnbound; + if (inspected.binding) { try { - console.warn('[tsjs-prebid] external Prebid artifact is incompatible'); + shouldDiagnose = !diagnosedBindings.has(inspected.binding); } catch { - // Diagnostics cannot change readiness behavior. + 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. + } } } } @@ -584,12 +628,22 @@ export function createBrowserPrebidAdapter( 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 => { - if (operation.timeout !== undefined) { - clearTimeout(operation.timeout); - operation.timeout = undefined; + try { + if (operation.timeout !== undefined) { + clearTimeout(operation.timeout); + operation.timeout = undefined; + } + removePending(operation); + } finally { + releasePendingReservation(operation); } - removePending(operation); }; const detachAbort = (operation: PendingOperation): void => { @@ -608,6 +662,17 @@ export function createBrowserPrebidAdapter( } }; + 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); @@ -615,7 +680,7 @@ export function createBrowserPrebidAdapter( try { detachAbort(operation); } finally { - live.delete(operation); + deleteSetValue(live, operation); } } }; @@ -670,7 +735,7 @@ export function createBrowserPrebidAdapter( const release = (): void => { if (promoted) { try { - effects.delete(release); + deleteSetValue(effects, release); } catch { // A hostile registry cannot prevent exact external cleanup. } @@ -856,20 +921,34 @@ export function createBrowserPrebidAdapter( const armNotification = (): void => { const current = currentBinding(); if (disposed) return; - if ( - current.status !== 'pending' || - !current.binding || - !current.commandQueue || - armedBindings.has(current.binding) - ) { + 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; - armedBindings.add(current.binding); try { - queueCommand(current.commandQueue, () => notifyReady(current.binding)); + 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 { - // A later script-owned notification or operation may observe a replacement. + notificationActive = false; + rollbackNotificationArming(current.binding); } }; @@ -880,8 +959,11 @@ export function createBrowserPrebidAdapter( if (disposed) throw new PrebidAdapterError('operation_disposed'); const current = currentBinding(); if (disposed) throw new PrebidAdapterError('operation_disposed'); - if (current.status === 'pending' && pending.length >= MAX_PENDING_OPERATIONS) { - throw new PrebidAdapterError('external_queue_full'); + if (current.status === 'pending') { + if (pendingReservations >= MAX_PENDING_OPERATIONS) { + throw new PrebidAdapterError('external_queue_full'); + } + pendingReservations += 1; } let resolve!: (value: T | PromiseLike) => void; @@ -893,6 +975,7 @@ export function createBrowserPrebidAdapter( const operation: PendingOperation = { state: current.status, settled: false, + pendingReservation: current.status === 'pending', timeout: undefined, command, resolve, @@ -901,7 +984,6 @@ export function createBrowserPrebidAdapter( readinessBinding: current.status === 'pending' ? current.binding : undefined, provisionalEffects: [], }; - live.add(operation as PendingOperation); const handle = Object.freeze({ get status(): PrebidOperationStatus { return operation.state; @@ -910,8 +992,19 @@ export function createBrowserPrebidAdapter( 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.push(operation as PendingOperation); + pending[pending.length] = operation as PendingOperation; operation.timeout = setTimeout( () => fail(operation as PendingOperation, 'external_ready_timeout'), EXTERNAL_READY_TIMEOUT_MS @@ -999,6 +1092,22 @@ export function createBrowserPrebidAdapter( 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') { @@ -1021,7 +1130,7 @@ export function createBrowserPrebidAdapter( for (const operation of [...live]) fail(operation, 'operation_disposed'); for (const disposeEffect of [...effects]) { try { - effects.delete(disposeEffect); + deleteSetValue(effects, disposeEffect); } catch { // A hostile registry cannot interrupt cleanup of remaining effects. } diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index 4ac71330d..1a6c1a77e 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -95,6 +95,152 @@ describe('browser googletag adapter readiness', () => { expect(second.status).toBe('present'); }); + it.each(['before', 'after'] as const)( + 'recovers GPT notification arming when WeakSet.add throws %s insertion', + async (failure) => { + const readinessCommands: Command[] = []; + const target: { googletag?: unknown } = { googletag: { cmd: readinessCommands } }; + const adapter = createBrowserGoogletagAdapter(target); + const originalWeakSetAdd = WeakSet.prototype.add; + WeakSet.prototype.add = function (this: WeakSet, value: object): WeakSet { + if (failure === 'after') Reflect.apply(originalWeakSetAdd, this, [value]); + throw new Error(`GPT arming failed ${failure} insertion`); + } as typeof WeakSet.prototype.add; + + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(() => 'ready'); + } catch (error) { + thrown = error; + } finally { + WeakSet.prototype.add = originalWeakSetAdd; + } + + expect(thrown).toBeUndefined(); + if (!operation) throw new Error('Expected a published GPT operation'); + expect(readinessCommands).toHaveLength(0); + adapter.notifyReady(); + expect(readinessCommands).toHaveLength(1); + target.googletag = createReadyGoogletag().googletag; + readinessCommands[0]?.(); + await expect(operation.result).resolves.toBe('ready'); + adapter.dispose(); + } + ); + + it('recovers GPT notification arming when WeakSet.has throws after publication', async () => { + vi.useFakeTimers(); + const readinessCommands: Command[] = []; + const target: { googletag?: unknown } = { googletag: { cmd: readinessCommands } }; + const adapter = createBrowserGoogletagAdapter(target); + const order: number[] = []; + const originalWeakSetHas = WeakSet.prototype.has; + WeakSet.prototype.has = function (): boolean { + throw new Error('GPT armed lookup failed'); + } as typeof WeakSet.prototype.has; + + let first: ReturnType | undefined; + let thrown: unknown; + try { + first = adapter.run(() => order.push(1)); + } catch (error) { + thrown = error; + } finally { + WeakSet.prototype.has = originalWeakSetHas; + } + + expect(thrown).toBeUndefined(); + if (!first) throw new Error('Expected a published GPT operation'); + expect(readinessCommands).toHaveLength(1); + const second = adapter.run(() => order.push(2)); + expect(readinessCommands).toHaveLength(1); + target.googletag = createReadyGoogletag().googletag; + readinessCommands[0]?.(); + await expect(Promise.all([first.result, second.result])).resolves.toEqual([1, 2]); + expect(order).toEqual([1, 2]); + expect(vi.getTimerCount()).toBe(0); + + target.googletag = undefined; + const recovered = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + const recoveredResults = recovered.map(({ result }) => result.catch((error) => error)); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + for (const operation of recovered) operation.dispose(); + await Promise.all(recoveredResults); + expect(vi.getTimerCount()).toBe(0); + adapter.dispose(); + }); + + it.each([ + { pushFailure: 'before', deleteFailure: 'throw' }, + { pushFailure: 'after', deleteFailure: 'retain' }, + ] as const)( + 'retries GPT notification registration after $pushFailure enqueue failure and $deleteFailure rollback', + async ({ pushFailure, deleteFailure }) => { + vi.useFakeTimers(); + const readinessCommands: Command[] = []; + let queueBroken = true; + const push = vi.fn((command: Command): number => { + if (queueBroken) { + if (pushFailure === 'after') readinessCommands.push(command); + throw new Error(`GPT queue failed ${pushFailure} enqueue`); + } + readinessCommands.push(command); + return readinessCommands.length; + }); + const target: { googletag?: unknown } = { googletag: { cmd: { push } } }; + const adapter = createBrowserGoogletagAdapter(target); + const order: number[] = []; + const originalWeakSetDelete = WeakSet.prototype.delete; + WeakSet.prototype.delete = function (): boolean { + if (deleteFailure === 'throw') throw new Error('GPT arming rollback failed'); + return false; + } as typeof WeakSet.prototype.delete; + + let first: ReturnType | undefined; + let thrown: unknown; + try { + first = adapter.run(() => order.push(1)); + } catch (error) { + thrown = error; + } finally { + WeakSet.prototype.delete = originalWeakSetDelete; + } + + expect(thrown).toBeUndefined(); + if (!first) throw new Error('Expected a published GPT operation'); + expect(readinessCommands).toHaveLength(pushFailure === 'after' ? 1 : 0); + queueBroken = false; + const second = adapter.run(() => order.push(2)); + expect(readinessCommands).toHaveLength(pushFailure === 'after' ? 2 : 1); + + target.googletag = createReadyGoogletag().googletag; + if (pushFailure === 'after') { + readinessCommands[0]?.(); + expect(order).toEqual([]); + } + readinessCommands[readinessCommands.length - 1]?.(); + await expect(Promise.all([first.result, second.result])).resolves.toEqual([1, 2]); + expect(order).toEqual([1, 2]); + for (const notify of readinessCommands) notify(); + expect(order).toEqual([1, 2]); + expect(vi.getTimerCount()).toBe(0); + + target.googletag = undefined; + const recovered = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + const recoveredResults = recovered.map(({ result }) => result.catch((error) => error)); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + for (const operation of recovered) operation.dispose(); + await Promise.all(recoveredResults); + expect(vi.getTimerCount()).toBe(0); + adapter.dispose(); + } + ); + it('rejects a queued operation when its pending GPT stub becomes incompatible', async () => { const readinessCommands: Command[] = []; const ready = createReadyGoogletag(); @@ -279,6 +425,117 @@ describe('browser googletag adapter readiness', () => { expect(order).toEqual(Array.from({ length: 64 }, (_, index) => index)); }); + it('reserves pending GPT capacity before poisoned Set.add reenters', async () => { + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const accepted: Array> = []; + const overflows: unknown[] = []; + const order: number[] = []; + const originalSetAdd = Set.prototype.add; + let reentered = false; + Set.prototype.add = function (this: Set, value: unknown): Set { + if (!reentered) { + reentered = true; + Set.prototype.add = originalSetAdd; + for (let index = 1; index <= 64; index += 1) { + try { + accepted.push(adapter.run(() => order.push(index))); + } catch (error) { + overflows.push(error); + } + } + } + return Reflect.apply(originalSetAdd, this, [value]) as Set; + } as typeof Set.prototype.add; + + let outer: ReturnType | undefined; + try { + outer = adapter.run(() => order.push(0)); + } finally { + Set.prototype.add = originalSetAdd; + } + if (!outer) throw new Error('Expected a published GPT operation'); + + expect(accepted).toHaveLength(63); + expect(overflows).toHaveLength(1); + expect(overflows[0]).toMatchObject({ code: 'external_queue_full' }); + + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + await expect( + Promise.all([outer.result, ...accepted.map(({ result }) => result)]) + ).resolves.toHaveLength(64); + expect(order).toEqual([...Array.from({ length: 63 }, (_, index) => index + 1), 0]); + + target.googletag = undefined; + const recovered = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + await expect(Promise.all(recovered.map(({ result }) => result))).resolves.toHaveLength(64); + }); + + it('rolls back pending GPT publication when poisoned Set.add throws', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const publicationError = new Error('GPT publication failed'); + const command = vi.fn(); + const signalGetter = vi.fn(() => undefined); + const options = Object.defineProperty({}, 'signal', { + get: signalGetter, + }) as { readonly signal?: AbortSignal }; + const originalSetAdd = Set.prototype.add; + const originalSetDelete = Set.prototype.delete; + const poisonedDelete = function (): boolean { + throw new Error('GPT publication rollback delete failed'); + } as typeof Set.prototype.delete; + let poisonNextAdd = true; + Set.prototype.add = function (this: Set, value: unknown): Set { + if (poisonNextAdd) { + poisonNextAdd = false; + Set.prototype.add = originalSetAdd; + Reflect.apply(originalSetAdd, this, [value]); + throw publicationError; + } + return Reflect.apply(originalSetAdd, this, [value]) as Set; + } as typeof Set.prototype.add; + Set.prototype.delete = poisonedDelete; + + let thrown: unknown; + try { + adapter.run(command, options); + } catch (error) { + thrown = error; + } finally { + Set.prototype.add = originalSetAdd; + Set.prototype.delete = originalSetDelete; + } + + expect(thrown).toBe(publicationError); + expect(command).not.toHaveBeenCalled(); + expect(signalGetter).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + + const recovered = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + await expect(Promise.all(recovered.map(({ result }) => result))).resolves.toHaveLength(64); + expect(vi.getTimerCount()).toBe(0); + Set.prototype.delete = poisonedDelete; + try { + expect(() => adapter.dispose()).not.toThrow(); + } finally { + Set.prototype.delete = originalSetDelete; + } + await Promise.resolve(); + }); + it.each(['signal-getter', 'aborted-getter', 'add-throw', 'abort-remove-throw'] as const)( 'contains hostile GPT AbortSignal ownership for %s', async (failure) => { @@ -341,6 +598,80 @@ describe('browser googletag adapter readiness', () => { } ); + it.each([ + 'add-getter', + 'before-install', + 'after-install', + 'reentrant-callback', + 'post-check-throw', + ] as const)( + 'settles GPT abort transitions during listener registration for %s', + async (transition) => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const signalError = new Error(`abort transition failure: ${transition}`); + const listeners = new Set<() => void>(); + let aborted = false; + let abortedReads = 0; + const addEventListener = vi.fn((_type: string, listener: () => void) => { + if (transition === 'before-install') aborted = true; + listeners.add(listener); + if (transition === 'after-install') aborted = true; + if (transition === 'reentrant-callback') { + aborted = true; + listener(); + } + }); + const removeEventListener = vi.fn((_type: string, listener: () => void) => { + listeners.delete(listener); + }); + const signal = Object.defineProperties( + {}, + { + aborted: { + get: () => { + abortedReads += 1; + if (transition === 'post-check-throw' && abortedReads === 2) throw signalError; + return aborted; + }, + }, + addEventListener: { + get: () => { + if (transition === 'add-getter') aborted = true; + return addEventListener; + }, + }, + removeEventListener: { value: removeEventListener }, + } + ) as AbortSignal; + const command = vi.fn(); + const operation = adapter.run(command, { signal }); + const result = operation.result.catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(10_000); + if (transition === 'post-check-throw') await expect(result).resolves.toBe(signalError); + else await expect(result).resolves.toMatchObject({ code: 'caller_aborted' }); + expect(addEventListener).toHaveBeenCalledTimes(1); + expect(removeEventListener).toHaveBeenCalledTimes(1); + expect(listeners).toHaveLength(0); + + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + expect(command).not.toHaveBeenCalled(); + + target.googletag = undefined; + const fillers = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + adapter.dispose(); + await Promise.all( + fillers.map(({ result: filler }) => filler.catch((error: unknown) => error)) + ); + } + ); + it('uses one exact independent ten-second deadline per enqueued operation', async () => { vi.useFakeTimers(); const adapter = createBrowserGoogletagAdapter({}); @@ -770,6 +1101,581 @@ describe('browser googletag adapter readiness', () => { } ); + it('settles a live GPT operation and restores exact effects when Set.delete is poisoned', async () => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const originalSetDelete = Set.prototype.delete; + ready.pubads.removeEventListener.mockImplementation((type, listener) => { + const registered = ready.listeners.get(type); + if (registered) Reflect.apply(originalSetDelete, registered, [listener]); + }); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const listener = vi.fn(); + const operation = adapter.run((gpt) => { + gpt.subscribe('slotRequested', listener); + return new Promise(() => undefined); + }); + expect(ready.googletag.setConfig).not.toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).not.toBe(nativeDisable); + expect(ready.listeners.get('slotRequested')).toHaveLength(1); + + Set.prototype.delete = function (): boolean { + throw new Error('GPT live cleanup delete failed'); + } as typeof Set.prototype.delete; + try { + expect(() => adapter.dispose()).not.toThrow(); + } finally { + Set.prototype.delete = originalSetDelete; + } + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(ready.listeners.get('slotRequested')).toHaveLength(0); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + expect(() => adapter.dispose()).not.toThrow(); + }); + + it.each(['keys', 'clear'] as const)( + 'restores every GPT effect when Map.%s is poisoned during disposal', + async (method) => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const listener = vi.fn(); + await adapter.run((gpt) => gpt.subscribe('slotRequested', listener)).result; + expect(ready.googletag.setConfig).not.toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).not.toBe(nativeDisable); + expect(ready.listeners.get('slotRequested')).toHaveLength(1); + + const originalMapKeys = Map.prototype.keys; + const originalMapClear = Map.prototype.clear; + if (method === 'keys') { + Map.prototype.keys = function (): never { + throw new Error('GPT initial-load keys failed'); + } as typeof Map.prototype.keys; + } else { + Map.prototype.clear = function (): never { + throw new Error('GPT initial-load clear failed'); + } as typeof Map.prototype.clear; + } + try { + expect(() => adapter.dispose()).not.toThrow(); + expect(() => adapter.dispose()).not.toThrow(); + } finally { + Map.prototype.keys = originalMapKeys; + Map.prototype.clear = originalMapClear; + } + + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + expect(ready.listeners.get('slotRequested')).toHaveLength(0); + expect(ready.pubads.removeEventListener).toHaveBeenCalledTimes(1); + + const ownershipProbe = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await ownershipProbe.run((gpt) => gpt.serviceState()).result; + expect(ready.googletag.setConfig).not.toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).not.toBe(nativeDisable); + ownershipProbe.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + } + ); + + it('settles GPT tracker publication failure and rolls back only its new owner', async () => { + const first = createReadyGoogletag(); + const second = createReadyGoogletag(); + const firstNativeSetConfig = first.googletag.setConfig; + const firstNativeDisable = first.pubads.disableInitialLoad; + const secondNativeSetConfig = second.googletag.setConfig; + const secondNativeDisable = second.pubads.disableInitialLoad; + const target: { googletag?: unknown } = { googletag: first.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + const priorListener = vi.fn(); + await adapter.run((gpt) => gpt.subscribe('prior', priorListener)).result; + expect(first.listeners.get('prior')).toHaveLength(1); + + target.googletag = second.googletag; + const registryError = new Error('GPT tracker effect publication failed'); + const command = vi.fn(); + const originalSetAdd = Set.prototype.add; + let additions = 0; + Set.prototype.add = function (this: Set, value: unknown): Set { + additions += 1; + const added = Reflect.apply(originalSetAdd, this, [value]) as Set; + if (additions === 3) throw registryError; + return added; + } as typeof Set.prototype.add; + + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(command); + } catch (error) { + thrown = error; + } finally { + Set.prototype.add = originalSetAdd; + } + + expect(additions).toBe(3); + expect(thrown).toBeUndefined(); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).rejects.toBe(registryError); + expect(command).not.toHaveBeenCalled(); + expect(first.listeners.get('prior')).toHaveLength(1); + expect(first.googletag.setConfig).toBe(firstNativeSetConfig); + expect(first.pubads.disableInitialLoad).toBe(firstNativeDisable); + expect(second.googletag.setConfig).toBe(secondNativeSetConfig); + expect(second.pubads.disableInitialLoad).toBe(secondNativeDisable); + + const ownerProbe = createBrowserGoogletagAdapter({ googletag: second.googletag }); + await ownerProbe.run((gpt) => gpt.serviceState()).result; + expect(second.googletag.setConfig).not.toBe(secondNativeSetConfig); + expect(second.pubads.disableInitialLoad).not.toBe(secondNativeDisable); + ownerProbe.dispose(); + expect(second.googletag.setConfig).toBe(secondNativeSetConfig); + expect(second.pubads.disableInitialLoad).toBe(secondNativeDisable); + + target.googletag = undefined; + const recovered = Array.from({ length: 64 }, () => adapter.run(() => 'recovered')); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + target.googletag = second.googletag; + adapter.notifyReady(); + await expect(Promise.all(recovered.map(({ result }) => result))).resolves.toEqual( + Array.from({ length: 64 }, () => 'recovered') + ); + expect(first.listeners.get('prior')).toHaveLength(1); + + adapter.dispose(); + expect(first.listeners.get('prior')).toHaveLength(0); + expect(first.pubads.removeEventListener).toHaveBeenCalledTimes(1); + expect(second.googletag.setConfig).toBe(secondNativeSetConfig); + expect(second.pubads.disableInitialLoad).toBe(secondNativeDisable); + }); + + it.each(['owner', 'release', 'service'] as const)( + 'settles and rolls back GPT tracking when the %s registry has lookup throws', + async (registry) => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const command = vi.fn(() => 'settled'); + const originalSetHas = Set.prototype.has; + const originalMapHas = Map.prototype.has; + const originalWeakMapHas = WeakMap.prototype.has; + if (registry === 'owner') { + Set.prototype.has = function (): boolean { + throw new Error('GPT owner lookup failed'); + } as typeof Set.prototype.has; + } else if (registry === 'release') { + Map.prototype.has = function (): boolean { + throw new Error('GPT release lookup failed'); + } as typeof Map.prototype.has; + } else { + WeakMap.prototype.has = function (): boolean { + throw new Error('GPT service lookup failed'); + } as typeof WeakMap.prototype.has; + } + + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(command); + } catch (error) { + thrown = error; + } finally { + Set.prototype.has = originalSetHas; + Map.prototype.has = originalMapHas; + WeakMap.prototype.has = originalWeakMapHas; + } + + expect(thrown).toBeUndefined(); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).resolves.toBe('settled'); + expect(command).toHaveBeenCalledTimes(1); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + + const ownerProbe = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await ownerProbe.run((gpt) => gpt.serviceState()).result; + ownerProbe.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + adapter.dispose(); + } + ); + + it.each(['before', 'after'] as const)( + 'rolls back shared GPT tracker publication when WeakMap.set throws %s insertion', + async (failure) => { + vi.useFakeTimers(); + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const target: { googletag?: unknown } = { googletag: ready.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + const publicationError = new Error(`shared tracker failed ${failure} insertion`); + const command = vi.fn(); + const originalWeakMapSet = WeakMap.prototype.set; + const originalWeakMapDelete = WeakMap.prototype.delete; + let publications = 0; + WeakMap.prototype.set = function ( + this: WeakMap, + key: object, + value: unknown + ): WeakMap { + publications += 1; + if (publications === 1) { + if (failure === 'after') Reflect.apply(originalWeakMapSet, this, [key, value]); + throw publicationError; + } + return Reflect.apply(originalWeakMapSet, this, [key, value]) as WeakMap; + } as typeof WeakMap.prototype.set; + WeakMap.prototype.delete = function (): boolean { + throw new Error('shared tracker rollback delete failed'); + } as typeof WeakMap.prototype.delete; + + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(command); + } catch (error) { + thrown = error; + } finally { + WeakMap.prototype.set = originalWeakMapSet; + WeakMap.prototype.delete = originalWeakMapDelete; + } + + expect(thrown).toBeUndefined(); + expect(publications).toBe(1); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).rejects.toBe(publicationError); + expect(command).not.toHaveBeenCalled(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + + const ownerProbe = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + let recoveredPublications = 0; + WeakMap.prototype.set = function ( + this: WeakMap, + key: object, + value: unknown + ): WeakMap { + recoveredPublications += 1; + return Reflect.apply(originalWeakMapSet, this, [key, value]) as WeakMap; + } as typeof WeakMap.prototype.set; + try { + await ownerProbe.run((gpt) => gpt.serviceState()).result; + } finally { + WeakMap.prototype.set = originalWeakMapSet; + } + expect(recoveredPublications).toBe(2); + ownerProbe.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + + target.googletag = undefined; + const recovered = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + const recoveredResults = recovered.map(({ result }) => result.catch((error) => error)); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + for (const recoveredOperation of recovered) recoveredOperation.dispose(); + await Promise.all(recoveredResults); + expect(vi.getTimerCount()).toBe(0); + adapter.dispose(); + } + ); + + it.each(['before', 'after'] as const)( + 'removes only the new GPT owner when its release Map.set throws %s insertion', + async (failure) => { + vi.useFakeTimers(); + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const first = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await first.run((gpt) => gpt.serviceState()).result; + const sharedSetConfig = ready.googletag.setConfig; + const sharedDisable = ready.pubads.disableInitialLoad; + const secondTarget: { googletag?: unknown } = { googletag: ready.googletag }; + const second = createBrowserGoogletagAdapter(secondTarget); + const publicationError = new Error(`release map failed ${failure} insertion`); + const command = vi.fn(); + const originalMapSet = Map.prototype.set; + const originalMapDelete = Map.prototype.delete; + let publications = 0; + Map.prototype.set = function ( + this: Map, + key: unknown, + value: unknown + ): Map { + publications += 1; + if (publications === 1) { + if (failure === 'after') Reflect.apply(originalMapSet, this, [key, value]); + throw publicationError; + } + return Reflect.apply(originalMapSet, this, [key, value]) as Map; + } as typeof Map.prototype.set; + Map.prototype.delete = function (): boolean { + throw new Error('release map rollback delete failed'); + } as typeof Map.prototype.delete; + + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = second.run(command); + } catch (error) { + thrown = error; + } finally { + Map.prototype.set = originalMapSet; + Map.prototype.delete = originalMapDelete; + } + + expect(thrown).toBeUndefined(); + expect(publications).toBe(1); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).rejects.toBe(publicationError); + expect(command).not.toHaveBeenCalled(); + expect(ready.googletag.setConfig).toBe(sharedSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(sharedDisable); + + first.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + + secondTarget.googletag = undefined; + const recovered = Array.from({ length: 64 }, () => second.run(vi.fn())); + const recoveredResults = recovered.map(({ result }) => result.catch((error) => error)); + expect(() => second.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + for (const recoveredOperation of recovered) recoveredOperation.dispose(); + await Promise.all(recoveredResults); + expect(vi.getTimerCount()).toBe(0); + second.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + } + ); + + it.each(['before', 'after'] as const)( + 'identity-restores GPT service publication when WeakMap.set throws %s insertion', + async (failure) => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const publicationError = new Error(`service map failed ${failure} insertion`); + const command = vi.fn(); + const originalWeakMapSet = WeakMap.prototype.set; + const originalWeakMapDelete = WeakMap.prototype.delete; + let publications = 0; + WeakMap.prototype.set = function ( + this: WeakMap, + key: object, + value: unknown + ): WeakMap { + publications += 1; + if (publications === 2) { + if (failure === 'after') Reflect.apply(originalWeakMapSet, this, [key, value]); + throw publicationError; + } + return Reflect.apply(originalWeakMapSet, this, [key, value]) as WeakMap; + } as typeof WeakMap.prototype.set; + WeakMap.prototype.delete = function (): boolean { + throw new Error('service map rollback delete failed'); + } as typeof WeakMap.prototype.delete; + + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(command); + } catch (error) { + thrown = error; + } finally { + WeakMap.prototype.set = originalWeakMapSet; + WeakMap.prototype.delete = originalWeakMapDelete; + } + + expect(thrown).toBeUndefined(); + expect(publications).toBe(2); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).rejects.toBe(publicationError); + expect(command).not.toHaveBeenCalled(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + + const ownerProbe = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await ownerProbe.run((gpt) => gpt.serviceState()).result; + ownerProbe.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + adapter.dispose(); + } + ); + + it.each(['before', 'after'] as const)( + 'rolls back GPT tracker owner publication when Set.add throws %s insertion', + async (failure) => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const first = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await first.run((gpt) => gpt.serviceState()).result; + const sharedSetConfig = ready.googletag.setConfig; + const sharedDisable = ready.pubads.disableInitialLoad; + const second = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const registryError = new Error(`owner add failed ${failure} insertion`); + const command = vi.fn(); + const originalSetAdd = Set.prototype.add; + const originalSetDelete = Set.prototype.delete; + let additions = 0; + Set.prototype.add = function (this: Set, value: unknown): Set { + additions += 1; + if (additions === 2) { + if (failure === 'after') Reflect.apply(originalSetAdd, this, [value]); + throw registryError; + } + return Reflect.apply(originalSetAdd, this, [value]) as Set; + } as typeof Set.prototype.add; + Set.prototype.delete = function (): boolean { + throw new Error('owner rollback delete failed'); + } as typeof Set.prototype.delete; + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = second.run(command); + } catch (error) { + thrown = error; + } finally { + Set.prototype.add = originalSetAdd; + Set.prototype.delete = originalSetDelete; + } + + expect(additions).toBe(2); + expect(thrown).toBeUndefined(); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).rejects.toBe(registryError); + expect(command).not.toHaveBeenCalled(); + expect(ready.googletag.setConfig).toBe(sharedSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(sharedDisable); + + second.dispose(); + expect(ready.googletag.setConfig).toBe(sharedSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(sharedDisable); + first.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + } + ); + + it.each(['before', 'after'] as const)( + 'identity-restores GPT root wrapper when restorer Set.add throws %s insertion', + async (failure) => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const registryError = new Error(`root restorer add failed ${failure} insertion`); + const command = vi.fn(); + const originalSetAdd = Set.prototype.add; + const originalSetDelete = Set.prototype.delete; + let additions = 0; + Set.prototype.add = function (this: Set, value: unknown): Set { + additions += 1; + if (additions === 4) { + if (failure === 'after') Reflect.apply(originalSetAdd, this, [value]); + throw registryError; + } + return Reflect.apply(originalSetAdd, this, [value]) as Set; + } as typeof Set.prototype.add; + Set.prototype.delete = function (): boolean { + throw new Error('root restorer cleanup delete failed'); + } as typeof Set.prototype.delete; + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(command); + } catch (error) { + thrown = error; + } finally { + Set.prototype.add = originalSetAdd; + Set.prototype.delete = originalSetDelete; + } + + expect(additions).toBe(4); + expect(thrown).toBeUndefined(); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).rejects.toBe(registryError); + expect(command).not.toHaveBeenCalled(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + + const ownerProbe = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await ownerProbe.run((gpt) => gpt.serviceState()).result; + ownerProbe.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + adapter.dispose(); + } + ); + + it.each(['before', 'after'] as const)( + 'identity-restores GPT service wrapper when restorer Set.add throws %s insertion', + async (failure) => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const registryError = new Error(`service restorer add failed ${failure} insertion`); + const command = vi.fn(); + const originalSetAdd = Set.prototype.add; + const originalSetDelete = Set.prototype.delete; + let additions = 0; + Set.prototype.add = function (this: Set, value: unknown): Set { + additions += 1; + if (additions === 5) { + if (failure === 'after') Reflect.apply(originalSetAdd, this, [value]); + throw registryError; + } + return Reflect.apply(originalSetAdd, this, [value]) as Set; + } as typeof Set.prototype.add; + Set.prototype.delete = function (): boolean { + throw new Error('service restorer cleanup delete failed'); + } as typeof Set.prototype.delete; + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(command); + } catch (error) { + thrown = error; + } finally { + Set.prototype.add = originalSetAdd; + Set.prototype.delete = originalSetDelete; + } + + expect(additions).toBe(5); + expect(thrown).toBeUndefined(); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).rejects.toBe(registryError); + expect(command).not.toHaveBeenCalled(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + + const ownerProbe = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await ownerProbe.run((gpt) => gpt.serviceState()).result; + ownerProbe.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + adapter.dispose(); + } + ); + it('rolls back a failed GPT command subscription without touching prior global effects', async () => { const ready = createReadyGoogletag(); const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); diff --git a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts index 739546891..0bf117525 100644 --- a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts @@ -164,6 +164,79 @@ describe('browser messaging adapter', () => { ).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) => @@ -531,14 +604,14 @@ describe('browser messaging adapter', () => { expect(raw.close).toHaveBeenCalledTimes(1); }); - it('rolls back both port listeners when messageerror installation or start fails', () => { - for (const failure of ['messageerror', 'start'] as const) { + 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 === 'messageerror' && type === 'messageerror') { - throw new Error('messageerror add failed'); + if (failure === type) { + throw new Error(`${type} add failed`); } }); if (failure === 'start') { @@ -557,9 +630,125 @@ describe('browser messaging adapter', () => { expect(raw.listeners.size).toBe(0); expect(raw.messageErrorListeners.size).toBe(0); expect(raw.removeEventListener).toHaveBeenCalledWith('message', expect.any(Function)); - expect(raw.removeEventListener).toHaveBeenCalledWith('messageerror', expect.any(Function)); - expect(raw.removeEventListener).toHaveBeenCalledTimes(2); + 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)( diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index 052500a8b..c6ce9f924 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -116,6 +116,152 @@ describe('browser Prebid adapter readiness', () => { 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 }; @@ -528,6 +674,108 @@ describe('browser Prebid adapter readiness', () => { } }); + 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 } = {}; @@ -781,6 +1029,35 @@ describe('browser Prebid adapter readiness', () => { } ); + 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 }); @@ -904,6 +1181,117 @@ describe('browser Prebid adapter readiness', () => { 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) => { @@ -966,6 +1354,80 @@ describe('browser Prebid adapter readiness', () => { } ); + 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 } = {}; From b3d4ecec952c3bf0d045450b6ae3867cad970d2f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:44:43 -0700 Subject: [PATCH 029/194] Implement the slot registry and physical GPT cycle model --- .../lib/src/adapters/googletag.ts | 264 +++- .../lib/src/composition/browser.ts | 156 +- .../lib/src/kernel/sessions.ts | 9 + .../lib/src/services/slots.ts | 1234 +++++++++++++++ .../lib/src/services/targeting.ts | 405 +++++ .../lib/test/composition/browser.test.ts | 137 ++ .../lib/test/services/slots.test.ts | 1375 +++++++++++++++++ .../lib/test/services/targeting.test.ts | 316 ++++ 8 files changed, 3782 insertions(+), 114 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/services/slots.ts create mode 100644 crates/trusted-server-js/lib/src/services/targeting.ts create mode 100644 crates/trusted-server-js/lib/test/services/slots.test.ts create mode 100644 crates/trusted-server-js/lib/test/services/targeting.test.ts diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index 9eb238869..253bd39ac 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -26,11 +26,25 @@ export class GoogletagAdapterError extends Error { } } +/** Immutable GPT definition used by the adapter-owned replacement transaction. */ +export interface GoogletagReplacementDefinition { + readonly adUnitPath: string; + readonly elementId: string; + readonly sizes: unknown; +} + +/** Observer called before a publisher-originated targeting mutation is forwarded. */ +export interface GoogletagTargetingObserver { + readonly beforePublisherMutation: (slot: object, key?: string) => void; +} + /** The small GPT surface exposed to an accepted operation. */ export interface GoogletagFacade { + bindingToken(): object; clearTargeting(slot: object, key?: string): unknown; display(slot: string | object): unknown; getTargeting(slot: object, key: string): readonly string[]; + observeTargeting(slot: object, observer: GoogletagTargetingObserver): () => void; refresh(slots?: readonly object[], options?: Readonly<{ changeCorrelator: boolean }>): unknown; serviceState(): Readonly<{ apiReady: boolean; @@ -40,6 +54,11 @@ export interface GoogletagFacade { setTargeting(slot: object, key: string, value: string | readonly string[]): unknown; slots(): readonly object[]; subscribe(eventType: string, listener: (event: unknown) => void): () => void; + transactionalReplace( + oldSlot: object, + definition: GoogletagReplacementDefinition | undefined, + isGenerationCurrent: () => boolean + ): object | undefined; } /** Options owned by one GPT operation. */ @@ -117,13 +136,24 @@ interface SharedInitialLoadTracker { readonly services: WeakMap void>; } +interface TargetingObservation { + 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 setSizeGetter = Object.getOwnPropertyDescriptor(Set.prototype, 'size')?.get as ( + this: Set +) => number; +const setValuesIntrinsic = Set.prototype.values; 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 { @@ -142,10 +172,26 @@ 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 setValues(set: Set): IterableIterator { + return Reflect.apply(setValuesIntrinsic, set, []) as IterableIterator; +} + +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; } @@ -234,7 +280,10 @@ function createFacade( registerEffect: (dispose: () => void) => () => void, isOperationCurrent: () => boolean, isBindingCurrent: () => boolean, - initialLoadDisabled: (service: object) => boolean + initialLoadDisabled: (service: object) => boolean, + targetingWrites: WeakMap, + targetingObservations: WeakMap, + bindingToken: object ): Readonly { const member = (external: object, key: PropertyKey): ((...args: unknown[]) => unknown) => { if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); @@ -260,9 +309,78 @@ function createFacade( return result; }; const service = (): object => asObject(call(binding.binding, 'pubads', [])); + const withTargetingWrite = (slot: object, callback: () => unknown): unknown => { + const depth = weakMapValue(targetingWrites, slot) ?? 0; + setWeakMapValue(targetingWrites, slot, depth + 1); + try { + return callback(); + } finally { + if (depth === 0) deleteWeakMapValue(targetingWrites, slot); + else setWeakMapValue(targetingWrites, slot, depth); + } + }; + const replaceObservedMethod = ( + slot: object, + key: 'clearTargeting' | 'setTargeting', + observer: GoogletagTargetingObserver + ): (() => void) | undefined => { + if (!isOperationCurrent()) return undefined; + const original = member(slot, key); + let descriptor: PropertyDescriptor | undefined; + let installed = false; + const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { + if ((weakMapValue(targetingWrites, slot) ?? 0) === 0) { + 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 (!installed) return; + installed = 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. + } + }; + 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() || !Reflect.defineProperty(slot, key, replacement)) { + return undefined; + } + installed = true; + if (!isOperationCurrent() || safeMember(slot, key) !== wrapper) { + restore(); + return undefined; + } + return restore; + } catch { + restore(); + return undefined; + } + }; return Object.freeze({ + bindingToken: (): object => bindingToken, clearTargeting: (slot: object, key?: string): unknown => - call(slot, 'clearTargeting', key === undefined ? [] : [key]), + withTargetingWrite(slot, () => call(slot, 'clearTargeting', key === undefined ? [] : [key])), display: (slot: string | object): unknown => call(binding.binding, 'display', [slot]), getTargeting: (slot: object, key: string): readonly string[] => { const targeting = call(slot, 'getTargeting', [key]); @@ -272,6 +390,79 @@ function createFacade( if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); return Object.freeze([...targeting]); }, + observeTargeting: (slot: object, observer: GoogletagTargetingObserver): (() => void) => { + 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 => { + for (const current of setValues(observers)) { + 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(); + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + let restored = false; + observation = { + observers, + restore: (): void => { + if (restored) return; + restored = true; + try { + restoreClear(); + } finally { + restoreSet(); + } + }, + }; + 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; + return 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(); + } + }); + }, refresh: ( slots?: readonly object[], options?: Readonly<{ changeCorrelator: boolean }> @@ -298,7 +489,9 @@ function createFacade( }); }, setTargeting: (slot: object, key: string, value: string | readonly string[]): unknown => - call(slot, 'setTargeting', [key, Array.isArray(value) ? [...value] : value]), + withTargetingWrite(slot, () => + call(slot, 'setTargeting', [key, Array.isArray(value) ? [...value] : value]) + ), slots: (): readonly object[] => { const currentSlots = call(service(), 'getSlots', []); if ( @@ -350,6 +543,58 @@ function createFacade( rollback(); }); }, + transactionalReplace: ( + oldSlot: object, + definition: GoogletagReplacementDefinition | undefined, + isGenerationCurrent: () => boolean + ): object | undefined => { + if (typeof isGenerationCurrent !== 'function' || !isOperationCurrent()) { + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + const destroy = (slot: object): boolean => { + try { + return call(binding.binding, 'destroySlots', [[slot]]) === true; + } catch { + return false; + } + }; + if (!destroy(oldSlot)) throw new Error('gpt_request_failed'); + if (definition === undefined || !isGenerationCurrent() || !isOperationCurrent()) { + return undefined; + } + let replacement: object | undefined; + try { + const candidate = call(binding.binding, 'defineSlot', [ + definition.adUnitPath, + definition.sizes, + definition.elementId, + ]); + if ( + (typeof candidate !== 'object' || candidate === null) && + typeof candidate !== 'function' + ) { + throw new Error('gpt_request_failed'); + } + replacement = candidate as object; + if (!isGenerationCurrent() || !isOperationCurrent()) { + const stale = replacement; + replacement = undefined; + if (!destroy(stale)) throw new Error('gpt_request_failed'); + return undefined; + } + call(replacement, 'addService', [service()]); + if (!isGenerationCurrent() || !isOperationCurrent()) { + const stale = replacement; + replacement = undefined; + if (!destroy(stale)) throw new Error('gpt_request_failed'); + return undefined; + } + return replacement; + } catch (error) { + if (replacement) destroy(replacement); + throw error; + } + }, }); } @@ -361,6 +606,9 @@ export function createBrowserGoogletagAdapter( const live = new Set>(); const effects = new Set<() => void>(); let armedBindings = new WeakSet(); + const targetingWrites = new WeakMap(); + const targetingObservations = new WeakMap(); + const bindingTokens = new WeakMap(); const initialLoadReleases = new Map void>(); const initialLoadOwner = Object.freeze({}); let pendingReservations = 0; @@ -1013,6 +1261,11 @@ export function createBrowserGoogletagAdapter( (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, @@ -1021,7 +1274,10 @@ export function createBrowserGoogletagAdapter( (service) => { const tracker = ensureInitialLoadTracking(binding, service); return tracker?.disabled === true; - } + }, + targetingWrites, + targetingObservations, + bindingToken ); try { if (disposed) { diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 38e77515b..3faece603 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -28,10 +28,10 @@ import { createAuctionContextRegistry, type AuctionContextRegistry } from '../se import { createPageBidsController, type PageBidsController, - type PreparedProjectionSlots, - type ProjectionSlotRegistry, prepareInitialAuctionProjection, } from '../services/projections'; +import { createSlotService, type SlotService } from '../services/slots'; +import { createTargetingService, type TargetingService } from '../services/targeting'; export interface BrowserAdapters { readonly googletag: GoogletagAdapter; @@ -43,6 +43,11 @@ export interface BrowserComposition { readonly adapters: Readonly; } +export interface BrowserServices { + readonly slots: SlotService; + readonly targeting: TargetingService; +} + export type BrowserAdapterTarget = GoogletagGlobalTarget & PrebidGlobalTarget & MessageEventTarget; export interface BrowserCompositionOptions { @@ -61,6 +66,10 @@ export interface BrowserRuntimeComposition extends BrowserComposition { 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; } export interface BrowserCoreActivations { @@ -70,7 +79,8 @@ export interface BrowserCoreActivations { ) => void; readonly correctnessGptListeners: ( context: CoreActivationContext, - adapters: Readonly + adapters: Readonly, + services: Readonly ) => void; } @@ -88,94 +98,6 @@ interface AcceptedBrowserBoot { }; } -class BrowserProjectionSlotLedger { - private readonly slots = new Map(); - - public bind( - navigation: NonNullable - ): ProjectionSlotRegistry { - return Object.freeze({ - prepareProjectionSlots: ( - ownerGeneration: object, - slots: readonly string[], - maximumActiveSlots: number - ): PreparedProjectionSlots | undefined => { - const ownedSlots = Object.freeze([...slots]); - if ( - ownerGeneration !== navigation.generation || - !navigation.isCurrent() || - ownedSlots.some((slot) => typeof slot !== 'string') || - new Set(ownedSlots).size !== ownedSlots.length || - this.slots.size + ownedSlots.length > maximumActiveSlots || - ownedSlots.some((slot) => this.slots.has(slot)) - ) { - return undefined; - } - let active = false; - let ownerDisposed = false; - const rollback = (): void => { - if (!active) return; - active = false; - for (const slot of ownedSlots) { - if (this.slots.get(slot) === ownerGeneration) this.slots.delete(slot); - } - }; - return Object.freeze({ - ownerGeneration, - commit: (): boolean => { - if ( - active || - ownerDisposed || - !navigation.isCurrent() || - this.slots.size + ownedSlots.length > maximumActiveSlots || - ownedSlots.some((slot) => this.slots.has(slot)) - ) { - return false; - } - navigation.onDispose('projection-slots', () => { - ownerDisposed = true; - rollback(); - }); - if (ownerDisposed || !navigation.isCurrent()) return false; - for (const slot of ownedSlots) this.slots.set(slot, ownerGeneration); - active = true; - return true; - }, - rollback, - }); - }, - }); - } - - public seed( - navigation: NonNullable, - slots: readonly string[] - ): boolean { - const reservation = this.bind(navigation).prepareProjectionSlots( - navigation.generation, - slots, - 256 - ); - return reservation?.commit() ?? false; - } - - public admitProgrammatic( - navigation: NonNullable, - slots: readonly string[] - ): boolean { - const reservation = this.bind(navigation).prepareProjectionSlots( - navigation.generation, - slots, - 256 - ); - return reservation?.commit() ?? false; - } - - public snapshotForTest(): readonly string[] { - return Object.freeze([...this.slots.keys()]); - } -} - function projectionSlots(projection: object): readonly string[] { const accepted = projection as { readonly auction: { readonly results: readonly { readonly slot: string }[] }; @@ -252,7 +174,7 @@ export function createTestBrowserRuntimeComposition( ): BrowserRuntimeComposition { const composition = createBrowserComposition(compositionOptions); let runtimeSession: RuntimeSession | undefined; - let projectionSlotLedger: BrowserProjectionSlotLedger | undefined; + let browserServices: Readonly | undefined; let auctionContextRegistry: AuctionContextRegistry | undefined; let projectionParser: ((candidate: unknown) => object | undefined) | undefined; const runtime = createRuntime({ @@ -266,18 +188,23 @@ export function createTestBrowserRuntimeComposition( parseProjection ); if (!initialProjection) throw new Error('Accepted boot projection is unavailable'); + const slotService = createSlotService({ googletag: composition.adapters.googletag }); + const targetingService = createTargetingService(); + const services = Object.freeze({ slots: slotService, targeting: targetingService }); const session = createRuntimeSession({ createIdentityIssuer: compositionOptions.createIdentityIssuerForTest ?? createBrowserNavigationIdentityIssuer, - interfaces: Object.freeze({ adapters: composition.adapters }), + interfaces: Object.freeze({ adapters: composition.adapters, ...services }), }); context.onDispose(() => { session.dispose(); + slotService.dispose(); + targetingService.dispose(); composition.adapters.googletag.dispose(); composition.adapters.prebid.dispose(); if (runtimeSession === session) { runtimeSession = undefined; - projectionSlotLedger = undefined; + browserServices = undefined; auctionContextRegistry = undefined; projectionParser = undefined; } @@ -285,31 +212,38 @@ export function createTestBrowserRuntimeComposition( const navigation = session.startInitialNavigation(initialProjection); if (!navigation.ok) throw new Error(navigation.reason); - const ledger = new BrowserProjectionSlotLedger(); - if ( - !ledger.admitProgrammatic( - navigation.value, - compositionOptions.admittedProgrammaticSlotsForTest ?? [] - ) - ) { - throw new Error('Initial programmatic slots exceed the shared registry'); - } - if (!ledger.seed(navigation.value, projectionSlots(initialProjection))) { - throw new Error('Initial projection slots exceed the shared registry'); + const initialRegistrations = [ + ...projectionSlots(initialProjection).map((registeredSlotId) => ({ + registeredSlotId, + 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)), runtimeOwner: session, }); runtimeSession = session; - projectionSlotLedger = ledger; + browserServices = services; auctionContextRegistry = contextRegistry; projectionParser = parseProjection; return runtimeOptions.activateOwner?.(context); }, activateCore: (context) => { + if (!browserServices) throw new Error('Browser services are unavailable'); compositionOptions.coreActivations.bridgeRecognizer(context, composition.adapters); - compositionOptions.coreActivations.correctnessGptListeners(context, composition.adapters); + browserServices.slots.activate(); + compositionOptions.coreActivations.correctnessGptListeners( + context, + composition.adapters, + browserServices + ); return runtimeOptions.activateCore?.(context); }, }); @@ -319,14 +253,16 @@ export function createTestBrowserRuntimeComposition( runtimeSessionForTest: () => runtimeSession, pageBidsControllerForTest: (): PageBidsController | undefined => { const navigation = runtimeSession?.currentNavigation; - if (!navigation || !projectionSlotLedger || !projectionParser) return undefined; + if (!navigation || !browserServices || !projectionParser) return undefined; return createPageBidsController({ navigation, parseProjection: projectionParser, - slotRegistry: projectionSlotLedger.bind(navigation), + slotRegistry: browserServices.slots.projectionRegistry(navigation), }); }, - projectionSlotsForTest: () => projectionSlotLedger?.snapshotForTest(), + projectionSlotsForTest: () => browserServices?.slots.registeredSlotIdsForTest(), auctionContextRegistryForTest: () => auctionContextRegistry, + slotServiceForTest: () => browserServices?.slots, + targetingServiceForTest: () => browserServices?.targeting, }); } diff --git a/crates/trusted-server-js/lib/src/kernel/sessions.ts b/crates/trusted-server-js/lib/src/kernel/sessions.ts index cdf81d0e5..dfb24da2b 100644 --- a/crates/trusted-server-js/lib/src/kernel/sessions.ts +++ b/crates/trusted-server-js/lib/src/kernel/sessions.ts @@ -78,6 +78,7 @@ export interface RuntimeSession { /** One route-local owner for aliases, intent, targeting, batches, attempts, and projection. */ export interface NavigationSession { readonly generation: object; + readonly interfaces: RuntimeInterfaces; readonly disposed: boolean; readonly currentAuctionProjection: Readonly | undefined; readonly signal: AbortSignal; @@ -98,6 +99,7 @@ export interface NavigationSession { /** Navigation-owned scope for one shared auction request and its child attempts. */ export interface AuctionBatchScope { readonly generation: object; + readonly interfaces: RuntimeInterfaces; readonly disposed: boolean; readonly signal: AbortSignal; readonly createRenderAttempt: (slot: string) => RenderAttemptResult; @@ -109,6 +111,7 @@ export interface AuctionBatchScope { /** Attempt-owned scope for timers, listeners, ports, and one terminal lifecycle. */ export interface RenderAttemptScope { readonly generation: object; + readonly interfaces: RuntimeInterfaces; readonly id: string; readonly slot: string; readonly disposed: boolean; @@ -204,6 +207,7 @@ class RenderAttemptOwner implements RenderAttemptScope { public constructor( public readonly id: string, public readonly slot: string, + public readonly interfaces: RuntimeInterfaces, private readonly ownerIsCurrent: () => boolean, onDisposalError?: DisposalErrorHandler ) { @@ -253,6 +257,7 @@ class AuctionBatchOwner implements AuctionBatchScope { public constructor( private readonly issuer: NavigationIdentityIssuer, + public readonly interfaces: RuntimeInterfaces, private readonly ownerIsCurrent: () => boolean, private readonly attemptExists: (slot: string) => boolean, private readonly registerAttempt: (slot: string, attempt: RenderAttemptOwner) => boolean, @@ -285,6 +290,7 @@ class AuctionBatchOwner implements AuctionBatchScope { const attempt = new RenderAttemptOwner( identity.value, slot, + this.interfaces, (): boolean => this.isCurrent() && this.attempts.get(slot) === attemptReference.current, this.onDisposalError ); @@ -346,6 +352,7 @@ class NavigationSessionOwner implements NavigationSession { public constructor( issuer: NavigationIdentityIssuer, initialProjection: Readonly | undefined, + public readonly interfaces: RuntimeInterfaces, ownerIsCurrent: () => boolean, onDisposing: () => DisposeCallback | undefined, onDisposed: () => void, @@ -403,6 +410,7 @@ class NavigationSessionOwner implements NavigationSession { const batchReference: { current?: AuctionBatchOwner } = {}; const batch = new AuctionBatchOwner( issuer, + this.interfaces, (): boolean => this.isCurrent() && this.batches.get(key) === batchReference.current, (slot) => this.attempts.has(slot), (slot, attempt) => { @@ -614,6 +622,7 @@ class RuntimeSessionOwner implements RuntimeSession { const navigation = new NavigationSessionOwner( identityIssuer, projection, + this.interfaces, () => !this.disposed && this.navigation === navigationReference.current, () => { const disposingNavigation = navigationReference.current; diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts new file mode 100644 index 000000000..993aaf109 --- /dev/null +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -0,0 +1,1234 @@ +import type { + GoogletagAdapter, + GoogletagFacade, + GoogletagOperation, + GoogletagReplacementDefinition, +} from '../adapters/googletag'; +import type { NavigationSession } from '../kernel/sessions'; + +import type { PreparedProjectionSlots, ProjectionSlotRegistry } from './projections'; + +/** Shared maximum across server-projected and programmatically admitted slots. */ +export const MAX_ACTIVE_SLOT_RECORDS = 256; + +const GPT_REQUEST_START_TIMEOUT_MS = 3_000; +const GPT_COMPLETION_TIMEOUT_MS = 10_000; + +export type SlotSource = 'programmatic' | 'server'; +export type GptSlotOwnership = 'publisher' | 'trusted_server'; + +/** Immutable registration input owned by one navigation. */ +export interface SlotRegistration { + readonly registeredSlotId: string; + readonly source: SlotSource; + readonly adUnitCode?: string; + readonly domAliases?: readonly string[]; +} + +/** Public immutable view of a registered slot. */ +export interface SlotRecord { + readonly adUnitCode: string | undefined; + readonly domAliases: readonly string[]; + readonly navigationGeneration: object; + readonly ordinal: number; + readonly registeredSlotId: string; + readonly source: SlotSource; +} + +export type SlotRegistrationFailure = + 'duplicate_slot' | 'invalid_slot_id' | 'registry_capacity' | 'stale_owner'; + +export type SlotRegistrationResult = + | Readonly<{ ok: true; records: readonly SlotRecord[] }> + | Readonly<{ ok: false; reason: SlotRegistrationFailure }>; + +/** Binding metadata required for safe TS-owned replacement. */ +export interface GptSlotBinding { + readonly definition?: GoogletagReplacementDefinition; + readonly ownership: GptSlotOwnership; + readonly slot: object; +} + +export type GptSlotAdoptionResult = Readonly< + | { ok: true } + | { + ok: false; + reason: + | 'gpt_object_collision' + | 'gpt_request_failed' + | 'slot_quarantined' + | 'slot_unresolved' + | 'stale_owner'; + } +>; + +export type SlotRequestFailure = + | 'cycle_unattributable' + | 'gpt_completion_timeout' + | 'gpt_request_failed' + | 'gpt_request_timeout' + | 'slot_quarantined' + | 'slot_unresolved'; + +export type SlotRequestOutcome = + | Readonly<{ + status: 'empty' | 'rendered'; + responseIdentifier?: string; + }> + | Readonly<{ status: 'failed'; reason: SlotRequestFailure }> + | Readonly<{ status: 'cancelled'; reason: 'navigation_disposed' | 'superseded' }>; + +export interface SlotRequestInput { + readonly intentId: string; + readonly navigationGeneration: object; + readonly operation: 'display' | 'refresh'; + readonly registeredSlotId: string; + readonly requestClass: string; +} + +export interface SlotRequestHandle { + readonly status: 'active' | 'queued' | 'terminal'; + readonly result: Promise; + readonly dispose: () => void; +} + +export type GptEventType = 'slotRenderEnded' | 'slotRequested'; + +export interface SlotServiceInventory { + readonly cycles: number; + readonly intents: number; + readonly physicalSlots: number; + readonly records: number; +} + +/** Runtime-owned slot registry and physical-cycle boundary. */ +export interface SlotService { + readonly activate: () => GoogletagOperation; + readonly adoptGptSlot: ( + navigationGeneration: object, + registeredSlotId: string, + binding: GptSlotBinding + ) => GptSlotAdoptionResult; + readonly dispose: () => void; + readonly handleGptEvent: (type: GptEventType, event: unknown) => void; + readonly prepareProjectionSlots: ( + owner: NavigationSession, + slots: readonly string[] + ) => PreparedProjectionSlots | undefined; + readonly projectionRegistry: (owner: NavigationSession) => ProjectionSlotRegistry; + readonly recordPublisherIntent: (slot: object) => boolean; + readonly registeredSlotIdsForTest: () => readonly string[]; + readonly register: ( + owner: NavigationSession, + registrations: readonly SlotRegistration[] + ) => SlotRegistrationResult; + readonly request: (input: SlotRequestInput) => SlotRequestHandle; + readonly requestBatch: (inputs: readonly SlotRequestInput[]) => readonly SlotRequestHandle[]; + readonly resolveAdUnitCode: (adUnitCode: string) => SlotRecord | undefined; + readonly resolveDomAlias: (alias: string) => SlotRecord | undefined; + readonly resolveRegisteredSlot: (registeredSlotId: string) => SlotRecord | undefined; + readonly snapshotForTest: () => SlotServiceInventory; +} + +export interface SlotServiceOptions { + readonly googletag: GoogletagAdapter; + readonly now?: () => number; +} + +interface NavigationState { + disposed: boolean; + nextOrdinal: number; + readonly owner: NavigationSession; + readonly records: Map; +} + +interface InternalSlotRecord { + activeIntent: RequestIntent | undefined; + physical: PhysicalSlot | undefined; + queuedIntent: RequestIntent | undefined; + readonly state: NavigationState; + readonly view: SlotRecord; +} + +type PhysicalSlotState = 'live' | 'quarantined' | 'retired'; + +interface PhysicalCycle { + intent: RequestIntent | undefined; + readonly kind: 'publisher' | 'trusted_server'; +} + +interface PhysicalSlot { + activeCycle: PhysicalCycle | undefined; + definition: GoogletagReplacementDefinition | undefined; + lastResponseIdentifier: string | undefined; + ownership: GptSlotOwnership; + publisherIntent: boolean; + quarantineReason: 'completion' | 'navigation' | 'request' | undefined; + record: InternalSlotRecord | undefined; + readonly slot: object; + state: PhysicalSlotState; +} + +interface RequestIntent { + completionTimer: ReturnType | undefined; + readonly input: SlotRequestInput; + invocation: GoogletagOperation | undefined; + requestStartedAt: number | undefined; + requestTimer: ReturnType | undefined; + readonly resolve: (outcome: SlotRequestOutcome) => void; + readonly result: Promise; + record: InternalSlotRecord; + state: 'active' | 'cycle' | 'queued' | 'terminal'; + terminal: boolean; +} + +interface BindingSubscriptions { + readonly release: () => void; + readonly token: object; +} + +interface BindingSubscriptionAdmission { + readonly installed: boolean; + readonly ownership: BindingSubscriptions; +} + +const mapDeleteIntrinsic = Map.prototype.delete; +const mapGetIntrinsic = Map.prototype.get; +const mapSetIntrinsic = Map.prototype.set; +const mapValuesIntrinsic = Map.prototype.values; +const mapSizeGetter = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.get as ( + this: Map +) => number; +const setAddIntrinsic = Set.prototype.add; +const setDeleteIntrinsic = Set.prototype.delete; +const setHasIntrinsic = Set.prototype.has; +const setValuesIntrinsic = Set.prototype.values; +const setSizeGetter = Object.getOwnPropertyDescriptor(Set.prototype, 'size')?.get as ( + this: Set +) => number; +const weakMapDeleteIntrinsic = WeakMap.prototype.delete; +const weakMapGetIntrinsic = WeakMap.prototype.get; +const weakMapSetIntrinsic = WeakMap.prototype.set; + +function mapValue(map: Map, key: Key): Value | undefined { + return Reflect.apply(mapGetIntrinsic, map, [key]) as Value | undefined; +} + +function setMapValue(map: Map, key: Key, value: Value): void { + Reflect.apply(mapSetIntrinsic, map, [key, value]); +} + +function deleteMapValue(map: Map, key: Key): boolean { + return Reflect.apply(mapDeleteIntrinsic, map, [key]) as boolean; +} + +function mapValues(map: Map): IterableIterator { + return Reflect.apply(mapValuesIntrinsic, map, []) as IterableIterator; +} + +function mapSize(map: Map): number { + return Reflect.apply(mapSizeGetter, map, []) as number; +} + +function addSetValue(set: Set, value: Value): void { + Reflect.apply(setAddIntrinsic, set, [value]); +} + +function deleteSetValue(set: Set, value: Value): boolean { + return Reflect.apply(setDeleteIntrinsic, set, [value]) as boolean; +} + +function setHasValue(set: Set, value: Value): boolean { + return Reflect.apply(setHasIntrinsic, set, [value]) as boolean; +} + +function setValues(set: Set): IterableIterator { + return Reflect.apply(setValuesIntrinsic, set, []) as IterableIterator; +} + +function setSize(set: Set): number { + return Reflect.apply(setSizeGetter, set, []) as number; +} + +function weakMapValue( + map: WeakMap, + key: Key +): Value | undefined { + return Reflect.apply(weakMapGetIntrinsic, map, [key]) as Value | undefined; +} + +function setWeakMapValue( + map: WeakMap, + key: Key, + value: Value +): void { + Reflect.apply(weakMapSetIntrinsic, map, [key, value]); +} + +function deleteWeakMapValue( + map: WeakMap, + key: Key +): boolean { + return Reflect.apply(weakMapDeleteIntrinsic, map, [key]) as boolean; +} + +function setIndexValue( + index: Map>, + key: string, + record: InternalSlotRecord +): void { + let records = mapValue(index, key); + if (!records) { + records = new Set(); + setMapValue(index, key, records); + } + try { + addSetValue(records, record); + } catch (error) { + if (setSize(records) === 0) deleteMapValue(index, key); + throw error; + } +} + +function deleteIndexValue( + index: Map>, + key: string, + record: InternalSlotRecord +): void { + const records = mapValue(index, key); + if (!records) return; + deleteSetValue(records, record); + if (setSize(records) === 0) deleteMapValue(index, key); +} + +function resolveUnique( + index: Map>, + key: string +): SlotRecord | undefined { + const records = mapValue(index, key); + if (!records || setSize(records) !== 1) return undefined; + const iterator = setValues(records); + const first = iterator.next(); + return first.done ? undefined : first.value.view; +} + +function validSlotIdentity(value: string): boolean { + return ( + value.length > 0 && new TextEncoder().encode(value).length <= 256 && !/[\p{Cc}]/u.test(value) + ); +} + +function frozenAliases(aliases: readonly string[] | undefined): readonly string[] | undefined { + if (aliases === undefined) return Object.freeze([]); + if (!Array.isArray(aliases)) return undefined; + const output: string[] = []; + const seen = new Set(); + for (const alias of aliases) { + if (typeof alias !== 'string' || !validSlotIdentity(alias) || setHasValue(seen, alias)) { + return undefined; + } + addSetValue(seen, alias); + output[output.length] = alias; + } + return Object.freeze(output); +} + +function ownData(event: unknown, key: PropertyKey): unknown { + if (typeof event !== 'object' || event === null) return undefined; + try { + const descriptor = Object.getOwnPropertyDescriptor(event, key); + return descriptor && 'value' in descriptor ? descriptor.value : undefined; + } catch { + return undefined; + } +} + +const failed = (reason: SlotRequestFailure): SlotRequestOutcome => + Object.freeze({ status: 'failed' as const, reason }); +const cancelled = (reason: 'navigation_disposed' | 'superseded'): SlotRequestOutcome => + Object.freeze({ status: 'cancelled' as const, reason }); + +/** Construct the document-lifetime slot registry and physical GPT cycle service. */ +export function createSlotService(options: SlotServiceOptions): SlotService { + const navigationStates = new Map(); + const registeredSlots = new Map(); + const adUnitCodes = new Map>(); + const domAliases = new Map>(); + const physicalByObject = new WeakMap(); + const physicalSlots = new Set(); + const now = options.now ?? (() => Date.now()); + let disposed = false; + let deferInvocations = false; + let activation: GoogletagOperation | undefined; + const subscriptionsByBinding = new WeakMap(); + const bindingSubscriptions = new Set(); + + const settle = (intent: RequestIntent, outcome: SlotRequestOutcome): void => { + if (intent.terminal) return; + intent.terminal = true; + intent.state = 'terminal'; + if (intent.requestTimer !== undefined) clearTimeout(intent.requestTimer); + if (intent.completionTimer !== undefined) clearTimeout(intent.completionTimer); + intent.requestTimer = undefined; + intent.completionTimer = undefined; + intent.invocation?.dispose(); + intent.invocation = undefined; + if (intent.record.activeIntent === intent) intent.record.activeIntent = undefined; + if (intent.record.queuedIntent === intent) intent.record.queuedIntent = undefined; + intent.resolve(outcome); + }; + + const failQueued = (record: InternalSlotRecord, reason: SlotRequestFailure): void => { + const queued = record.queuedIntent; + if (queued) settle(queued, failed(reason)); + }; + + const advanceQueued = (record: InternalSlotRecord): void => { + if (record.activeIntent || record.state.disposed || !record.state.owner.isCurrent()) return; + const queued = record.queuedIntent; + if (!queued) return; + const physical = record.physical; + if (!physical || physical.state !== 'live' || physical.activeCycle) return; + record.queuedIntent = undefined; + record.activeIntent = queued; + queued.state = 'active'; + invokeIntent(record, queued); + }; + + const bindReplacement = ( + record: InternalSlotRecord, + oldPhysical: PhysicalSlot, + replacement: object + ): boolean => { + if ( + record.physical !== oldPhysical || + record.state.disposed || + !record.state.owner.isCurrent() + ) { + return false; + } + const existing = weakMapValue(physicalByObject, replacement); + if (existing && existing !== oldPhysical) return false; + const physical: PhysicalSlot = { + activeCycle: undefined, + definition: oldPhysical.definition, + lastResponseIdentifier: undefined, + ownership: 'trusted_server', + publisherIntent: false, + quarantineReason: undefined, + record, + slot: replacement, + state: 'live', + }; + try { + setWeakMapValue(physicalByObject, replacement, physical); + addSetValue(physicalSlots, physical); + if (record.state.disposed || !record.state.owner.isCurrent()) throw new Error('stale owner'); + oldPhysical.record = undefined; + record.physical = physical; + deleteSetValue(physicalSlots, oldPhysical); + return true; + } catch { + deleteSetValue(physicalSlots, physical); + if (weakMapValue(physicalByObject, replacement) === physical) { + deleteWeakMapValue(physicalByObject, replacement); + } + return false; + } + }; + + const recoverRequestTimeout = (record: InternalSlotRecord, physical: PhysicalSlot): void => { + physical.state = 'retired'; + physical.quarantineReason = 'request'; + if ( + physical.ownership !== 'trusted_server' || + !physical.definition || + record.state.disposed || + !record.state.owner.isCurrent() + ) { + physical.state = 'quarantined'; + failQueued(record, 'gpt_request_failed'); + return; + } + let operation: GoogletagOperation; + try { + operation = options.googletag.run((gpt) => + gpt.transactionalReplace( + physical.slot, + physical.definition, + () => !record.state.disposed && record.state.owner.isCurrent() + ) + ); + } catch { + physical.state = 'quarantined'; + failQueued(record, 'gpt_request_failed'); + return; + } + void operation.result.then( + (replacement) => { + if (!replacement || !bindReplacement(record, physical, replacement)) { + physical.state = 'quarantined'; + failQueued(record, 'gpt_request_failed'); + return; + } + advanceQueued(record); + }, + () => { + physical.state = 'quarantined'; + failQueued(record, 'gpt_request_failed'); + } + ); + }; + + const cancelIntent = (intent: RequestIntent): void => { + if (intent.terminal) return; + const wasInvoked = intent.requestStartedAt !== undefined; + const physical = intent.record.physical; + if (intent.state === 'cycle' && physical?.activeCycle?.intent === intent) { + physical.activeCycle.intent = undefined; + physical.state = 'quarantined'; + physical.quarantineReason = 'completion'; + settle(intent, cancelled('superseded')); + return; + } + settle(intent, cancelled('superseded')); + if (wasInvoked && physical) recoverRequestTimeout(intent.record, physical); + }; + + const onRequestTimeout = (intent: RequestIntent): void => { + if (intent.terminal || intent.state !== 'active') return; + const physical = intent.record.physical; + settle(intent, failed('gpt_request_timeout')); + if (!physical) { + failQueued(intent.record, 'gpt_request_failed'); + return; + } + recoverRequestTimeout(intent.record, physical); + }; + + const onCompletionTimeout = (intent: RequestIntent): void => { + if (intent.terminal || intent.state !== 'cycle') return; + const physical = intent.record.physical; + if (physical?.activeCycle?.intent === intent) { + physical.activeCycle.intent = undefined; + physical.state = 'quarantined'; + physical.quarantineReason = 'completion'; + } + settle(intent, failed('gpt_completion_timeout')); + }; + + const armRequestDeadline = (intent: RequestIntent): void => { + intent.requestStartedAt = now(); + intent.requestTimer = setTimeout(() => onRequestTimeout(intent), GPT_REQUEST_START_TIMEOUT_MS); + }; + + const ensureBindingSubscriptions = ( + gpt: Readonly + ): BindingSubscriptionAdmission => { + const token = gpt.bindingToken(); + const existing = weakMapValue(subscriptionsByBinding, token); + if (existing) return { installed: false, ownership: existing }; + const releaseRequested = gpt.subscribe('slotRequested', (event) => + handleGptEvent('slotRequested', event) + ); + let releaseRendered: (() => void) | undefined; + try { + releaseRendered = gpt.subscribe('slotRenderEnded', (event) => + handleGptEvent('slotRenderEnded', event) + ); + } catch (error) { + releaseRequested(); + throw error; + } + let active = true; + const ownership: BindingSubscriptions = { + token, + release: (): void => { + if (!active) return; + active = false; + if (weakMapValue(subscriptionsByBinding, token) === ownership) { + deleteWeakMapValue(subscriptionsByBinding, token); + } + deleteSetValue(bindingSubscriptions, ownership); + try { + releaseRendered?.(); + } finally { + releaseRequested(); + } + }, + }; + try { + setWeakMapValue(subscriptionsByBinding, token, ownership); + addSetValue(bindingSubscriptions, ownership); + if (disposed) ownership.release(); + } catch (error) { + ownership.release(); + throw error; + } + return { installed: true, ownership }; + }; + + function invokeIntent(record: InternalSlotRecord, intent: RequestIntent): void { + if ( + intent.terminal || + record.activeIntent !== intent || + record.state.disposed || + !record.state.owner.isCurrent() + ) { + settle(intent, cancelled('navigation_disposed')); + return; + } + const physical = record.physical; + if (!physical || physical.state !== 'live' || physical.activeCycle) { + settle( + intent, + failed(physical?.state === 'quarantined' ? 'slot_quarantined' : 'slot_unresolved') + ); + return; + } + let subscriptions: BindingSubscriptionAdmission | undefined; + try { + const operation = options.googletag.run((gpt) => { + subscriptions = ensureBindingSubscriptions(gpt); + if ( + intent.terminal || + record.activeIntent !== intent || + record.physical !== physical || + record.state.disposed || + !record.state.owner.isCurrent() + ) { + return; + } + const state = gpt.serviceState(); + if (intent.input.operation === 'display' && state.initialLoadDisabled) { + gpt.display(physical.slot); + if (intent.terminal || physical.activeCycle) return; + armRequestDeadline(intent); + gpt.refresh([physical.slot], Object.freeze({ changeCorrelator: false })); + return; + } + armRequestDeadline(intent); + if (intent.input.operation === 'display') gpt.display(physical.slot); + else gpt.refresh([physical.slot], Object.freeze({ changeCorrelator: false })); + }); + intent.invocation = operation; + if (intent.terminal) operation.dispose(); + void operation.result.then( + () => undefined, + () => { + if (subscriptions?.installed) subscriptions.ownership.release(); + if (intent.terminal) return; + settle(intent, failed('gpt_request_failed')); + advanceQueued(record); + } + ); + } catch { + if (subscriptions?.installed) subscriptions.ownership.release(); + settle(intent, failed('gpt_request_failed')); + advanceQueued(record); + } + } + + const handleGptEvent = (type: GptEventType, event: unknown): void => { + const slot = ownData(event, 'slot'); + if ((typeof slot !== 'object' || slot === null) && typeof slot !== 'function') return; + const physical = weakMapValue(physicalByObject, slot as object); + if (!physical) return; + + if (type === 'slotRequested') { + if (physical.state !== 'live' || physical.activeCycle) return; + const record = physical.record; + const intent = record?.activeIntent; + if (physical.publisherIntent) { + physical.publisherIntent = false; + if (intent && !intent.terminal) settle(intent, failed('cycle_unattributable')); + physical.activeCycle = { intent: undefined, kind: 'publisher' }; + return; + } + if ( + intent && + !intent.terminal && + intent.state === 'active' && + intent.requestStartedAt !== undefined + ) { + if (intent.requestTimer !== undefined) clearTimeout(intent.requestTimer); + intent.requestTimer = undefined; + intent.state = 'cycle'; + physical.activeCycle = { intent, kind: 'trusted_server' }; + const elapsed = Math.max(0, now() - intent.requestStartedAt); + intent.completionTimer = setTimeout( + () => onCompletionTimeout(intent), + Math.max(0, GPT_COMPLETION_TIMEOUT_MS - elapsed) + ); + return; + } + if (intent && !intent.terminal) settle(intent, failed('cycle_unattributable')); + physical.activeCycle = { intent: undefined, kind: 'publisher' }; + return; + } + + const responseIdentifierValue = ownData(event, 'responseIdentifier'); + const responseIdentifier = + typeof responseIdentifierValue === 'string' ? responseIdentifierValue : undefined; + if ( + responseIdentifier !== undefined && + responseIdentifier === physical.lastResponseIdentifier + ) { + return; + } + const cycle = physical.activeCycle; + if (!cycle) return; + if (responseIdentifier !== undefined) physical.lastResponseIdentifier = responseIdentifier; + physical.activeCycle = undefined; + const intent = cycle.intent; + if (intent && !intent.terminal) { + const isEmpty = ownData(event, 'isEmpty') === true; + settle( + intent, + Object.freeze({ + ...(responseIdentifier === undefined ? {} : { responseIdentifier }), + status: isEmpty ? ('empty' as const) : ('rendered' as const), + }) + ); + advanceQueued(intent.record); + return; + } + if (physical.quarantineReason === 'completion' || physical.quarantineReason === 'navigation') { + const quarantineReason = physical.quarantineReason; + physical.quarantineReason = undefined; + if (quarantineReason === 'completion' || physical.ownership === 'publisher') { + physical.state = 'live'; + } + if (physical.record && physical.state === 'live') advanceQueued(physical.record); + else deleteSetValue(physicalSlots, physical); + } + }; + + const retirePhysicalForNavigation = (physical: PhysicalSlot): void => { + physical.record = undefined; + if (physical.ownership === 'publisher') { + if (physical.activeCycle) { + physical.state = 'quarantined'; + physical.quarantineReason = 'navigation'; + } + return; + } + if (physical.state === 'retired') return; + physical.state = 'retired'; + physical.quarantineReason = 'navigation'; + let operation: GoogletagOperation | undefined; + try { + operation = options.googletag.run((gpt) => + gpt.transactionalReplace(physical.slot, undefined, () => false) + ); + void operation.result.then( + () => { + if (!physical.activeCycle && !physical.record) deleteSetValue(physicalSlots, physical); + }, + () => { + if (!physical.activeCycle && !physical.record) deleteSetValue(physicalSlots, physical); + } + ); + } catch { + operation?.dispose(); + if (!physical.activeCycle && !physical.record) deleteSetValue(physicalSlots, physical); + } + }; + + const disposeNavigationState = (state: NavigationState): void => { + if (state.disposed) return; + state.disposed = true; + const records = [...mapValues(state.records)]; + for (const record of records) { + const active = record.activeIntent; + const queued = record.queuedIntent; + if (active) settle(active, cancelled('navigation_disposed')); + if (queued) settle(queued, cancelled('navigation_disposed')); + const physical = record.physical; + if (physical) retirePhysicalForNavigation(physical); + record.physical = undefined; + deleteMapValue(registeredSlots, record.view.registeredSlotId); + if (record.view.adUnitCode !== undefined) { + deleteIndexValue(adUnitCodes, record.view.adUnitCode, record); + } + for (const alias of record.view.domAliases) deleteIndexValue(domAliases, alias, record); + deleteMapValue(state.records, record.view.registeredSlotId); + } + deleteMapValue(navigationStates, state.owner.generation); + }; + + const stateForOwner = (owner: NavigationSession): NavigationState | undefined => { + if (disposed || !owner.isCurrent()) return undefined; + const existing = mapValue(navigationStates, owner.generation); + if (existing) return existing.disposed ? undefined : existing; + const state: NavigationState = { + disposed: false, + nextOrdinal: 0, + owner, + records: new Map(), + }; + let disposerInstalled = false; + try { + owner.onDispose('slot-records', () => disposeNavigationState(state)); + disposerInstalled = true; + if (!owner.isCurrent() || state.disposed) return undefined; + setMapValue(navigationStates, owner.generation, state); + if (!owner.isCurrent() || state.disposed) { + deleteMapValue(navigationStates, owner.generation); + return undefined; + } + return state; + } catch (error) { + if (disposerInstalled) disposeNavigationState(state); + throw error; + } + }; + + const register = ( + owner: NavigationSession, + registrations: readonly SlotRegistration[] + ): SlotRegistrationResult => { + if (disposed || !owner.isCurrent()) return Object.freeze({ ok: false, reason: 'stale_owner' }); + if (!Array.isArray(registrations)) { + return Object.freeze({ ok: false, reason: 'invalid_slot_id' }); + } + const prepared: Array<{ + readonly adUnitCode: string | undefined; + readonly aliases: readonly string[]; + readonly id: string; + readonly source: SlotSource; + }> = []; + const ids = new Set(); + for (const registration of registrations) { + if (typeof registration !== 'object' || registration === null) { + return Object.freeze({ ok: false, reason: 'invalid_slot_id' }); + } + const id = registration.registeredSlotId; + const source = registration.source; + const adUnitCode = registration.adUnitCode; + const aliases = frozenAliases(registration.domAliases); + if ( + typeof id !== 'string' || + !validSlotIdentity(id) || + (source !== 'server' && source !== 'programmatic') || + (adUnitCode !== undefined && + (typeof adUnitCode !== 'string' || !validSlotIdentity(adUnitCode))) || + aliases === undefined + ) { + return Object.freeze({ ok: false, reason: 'invalid_slot_id' }); + } + if (setHasValue(ids, id) || mapValue(registeredSlots, id)) { + return Object.freeze({ ok: false, reason: 'duplicate_slot' }); + } + addSetValue(ids, id); + prepared[prepared.length] = { adUnitCode, aliases, id, source }; + } + + let state: NavigationState | undefined; + try { + state = stateForOwner(owner); + } catch { + return Object.freeze({ ok: false, reason: 'stale_owner' }); + } + if (!state) return Object.freeze({ ok: false, reason: 'stale_owner' }); + if (mapSize(state.records) + prepared.length > MAX_ACTIVE_SLOT_RECORDS) { + return Object.freeze({ ok: false, reason: 'registry_capacity' }); + } + + const inserted: InternalSlotRecord[] = []; + try { + for (let index = 0; index < prepared.length; index += 1) { + const registration = prepared[index]; + if (!registration || !owner.isCurrent() || state.disposed) throw new Error('stale owner'); + const ordinal = state.nextOrdinal + index; + const view: SlotRecord = Object.freeze({ + adUnitCode: registration.adUnitCode, + domAliases: registration.aliases, + navigationGeneration: owner.generation, + ordinal, + registeredSlotId: registration.id, + source: registration.source, + }); + const record: InternalSlotRecord = { + activeIntent: undefined, + physical: undefined, + queuedIntent: undefined, + state, + view, + }; + setMapValue(registeredSlots, registration.id, record); + try { + setMapValue(state.records, registration.id, record); + if (registration.adUnitCode !== undefined) { + setIndexValue(adUnitCodes, registration.adUnitCode, record); + } + for (const alias of registration.aliases) setIndexValue(domAliases, alias, record); + } catch (error) { + deleteMapValue(registeredSlots, registration.id); + deleteMapValue(state.records, registration.id); + if (registration.adUnitCode !== undefined) { + deleteIndexValue(adUnitCodes, registration.adUnitCode, record); + } + for (const alias of registration.aliases) deleteIndexValue(domAliases, alias, record); + throw error; + } + inserted[inserted.length] = record; + } + if (!owner.isCurrent() || state.disposed) throw new Error('stale owner'); + state.nextOrdinal += prepared.length; + return Object.freeze({ ok: true, records: Object.freeze(inserted.map(({ view }) => view)) }); + } catch { + for (let index = inserted.length - 1; index >= 0; index -= 1) { + const record = inserted[index]; + if (!record) continue; + deleteMapValue(registeredSlots, record.view.registeredSlotId); + deleteMapValue(state.records, record.view.registeredSlotId); + if (record.view.adUnitCode !== undefined) { + deleteIndexValue(adUnitCodes, record.view.adUnitCode, record); + } + for (const alias of record.view.domAliases) deleteIndexValue(domAliases, alias, record); + } + return Object.freeze({ ok: false, reason: 'stale_owner' }); + } + }; + + const adoptGptSlot = ( + navigationGeneration: object, + registeredSlotId: string, + binding: GptSlotBinding + ): GptSlotAdoptionResult => { + const state = mapValue(navigationStates, navigationGeneration); + if (!state || state.disposed || !state.owner.isCurrent()) { + return Object.freeze({ ok: false, reason: 'stale_owner' }); + } + const record = mapValue(state.records, registeredSlotId); + if (!record) return Object.freeze({ ok: false, reason: 'slot_unresolved' }); + let slot: unknown; + let ownership: unknown; + let definition: GoogletagReplacementDefinition | undefined; + try { + slot = binding.slot; + ownership = binding.ownership; + definition = binding.definition; + } catch { + return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); + } + if (!state.owner.isCurrent() || state.disposed) { + return Object.freeze({ ok: false, reason: 'stale_owner' }); + } + if ((typeof slot !== 'object' || slot === null) && typeof slot !== 'function') { + return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); + } + if (ownership !== 'publisher' && ownership !== 'trusted_server') { + return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); + } + const slotObject = slot as object; + const existing = weakMapValue(physicalByObject, slotObject); + if (existing) { + if (existing.state === 'quarantined') { + return Object.freeze({ ok: false, reason: 'slot_quarantined' }); + } + if (existing.state === 'retired') { + return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); + } + if (existing.record && existing.record !== record) { + return Object.freeze({ ok: false, reason: 'gpt_object_collision' }); + } + existing.record = record; + existing.ownership = ownership; + existing.definition = definition; + record.physical = existing; + return Object.freeze({ ok: true }); + } + if (record.physical && record.physical.slot !== slotObject) { + return Object.freeze({ ok: false, reason: 'gpt_object_collision' }); + } + const physical: PhysicalSlot = { + activeCycle: undefined, + definition, + lastResponseIdentifier: undefined, + ownership, + publisherIntent: false, + quarantineReason: undefined, + record, + slot: slotObject, + state: 'live', + }; + try { + setWeakMapValue(physicalByObject, slotObject, physical); + addSetValue(physicalSlots, physical); + if (!state.owner.isCurrent() || state.disposed) throw new Error('stale owner'); + record.physical = physical; + return Object.freeze({ ok: true }); + } catch { + deleteSetValue(physicalSlots, physical); + if (weakMapValue(physicalByObject, slotObject) === physical) { + deleteWeakMapValue(physicalByObject, slotObject); + } + return Object.freeze({ ok: false, reason: 'stale_owner' }); + } + }; + + const request = (input: SlotRequestInput): SlotRequestHandle => { + const state = mapValue(navigationStates, input.navigationGeneration); + const record = state ? mapValue(state.records, input.registeredSlotId) : undefined; + let resolve!: (outcome: SlotRequestOutcome) => void; + const result = new Promise((resolveResult) => { + resolve = resolveResult; + }); + const placeholderRecord = + record ?? ({ activeIntent: undefined, queuedIntent: undefined } as InternalSlotRecord); + const intent: RequestIntent = { + completionTimer: undefined, + input, + invocation: undefined, + requestStartedAt: undefined, + requestTimer: undefined, + resolve, + result, + record: placeholderRecord, + state: 'terminal', + terminal: false, + }; + const handle = Object.freeze({ + get status(): 'active' | 'queued' | 'terminal' { + return intent.state === 'cycle' ? 'active' : intent.state; + }, + result, + dispose: (): void => cancelIntent(intent), + }); + if (!state || state.disposed || !state.owner.isCurrent() || !record) { + settle(intent, failed('slot_unresolved')); + return handle; + } + intent.record = record; + const physical = record.physical; + if (!physical) { + settle(intent, failed('slot_unresolved')); + return handle; + } + if (physical.state === 'retired' || physical.quarantineReason === 'request') { + settle(intent, failed('gpt_request_failed')); + return handle; + } + if (physical.state === 'quarantined') { + settle(intent, failed('slot_quarantined')); + return handle; + } + if (physical.publisherIntent) { + settle(intent, failed('cycle_unattributable')); + return handle; + } + if (record.activeIntent) { + intent.state = 'queued'; + const queued = record.queuedIntent; + if (queued) { + if (queued.input.requestClass !== input.requestClass) { + settle(queued, failed('cycle_unattributable')); + settle(intent, failed('cycle_unattributable')); + return handle; + } + settle(queued, cancelled('superseded')); + } + record.queuedIntent = intent; + return handle; + } + record.activeIntent = intent; + intent.state = 'active'; + if (!deferInvocations) invokeIntent(record, intent); + return handle; + }; + + const requestBatch = (inputs: readonly SlotRequestInput[]): readonly SlotRequestHandle[] => { + if (!Array.isArray(inputs)) return Object.freeze([]); + deferInvocations = true; + let handles: SlotRequestHandle[]; + try { + handles = inputs.map((input) => request(input)); + } finally { + deferInvocations = false; + } + const intents: RequestIntent[] = []; + for (const input of inputs) { + const state = mapValue(navigationStates, input.navigationGeneration); + const record = state ? mapValue(state.records, input.registeredSlotId) : undefined; + const intent = record?.activeIntent; + if (intent && intent.input === input && intent.state === 'active') + intents[intents.length] = intent; + } + if (intents.length > 0) { + const slots: object[] = []; + for (const intent of intents) { + const physical = intent.record.physical; + if (!physical || physical.state !== 'live' || physical.activeCycle) { + settle(intent, failed('slot_unresolved')); + continue; + } + slots[slots.length] = physical.slot; + } + if (slots.length === intents.length) { + let subscriptions: BindingSubscriptionAdmission | undefined; + try { + const operation = options.googletag.run((gpt) => { + subscriptions = ensureBindingSubscriptions(gpt); + for (const intent of intents) { + if (!intent.terminal) armRequestDeadline(intent); + } + gpt.refresh(slots, Object.freeze({ changeCorrelator: false })); + }); + for (const intent of intents) { + intent.invocation = operation; + if (intent.terminal) operation.dispose(); + } + void operation.result.then( + () => undefined, + () => { + if (subscriptions?.installed) subscriptions.ownership.release(); + for (const intent of intents) { + if (!intent.terminal) settle(intent, failed('gpt_request_failed')); + } + } + ); + } catch { + if (subscriptions?.installed) subscriptions.ownership.release(); + for (const intent of intents) { + if (!intent.terminal) settle(intent, failed('gpt_request_failed')); + } + } + } + } + return Object.freeze(handles); + }; + + const service: SlotService = Object.freeze({ + activate: (): GoogletagOperation => { + if (activation) return activation; + let subscriptions: BindingSubscriptionAdmission | undefined; + const operation = options.googletag.run((gpt) => { + if (disposed) return; + subscriptions = ensureBindingSubscriptions(gpt); + }); + activation = operation; + void operation.result.then( + () => { + if (activation === operation) activation = undefined; + }, + () => { + if (subscriptions?.installed) subscriptions.ownership.release(); + if (activation === operation) activation = undefined; + } + ); + return operation; + }, + adoptGptSlot, + dispose: (): void => { + if (disposed) return; + disposed = true; + for (const state of [...mapValues(navigationStates)]) disposeNavigationState(state); + const subscriptions = [...setValues(bindingSubscriptions)]; + for (let index = subscriptions.length - 1; index >= 0; index -= 1) { + try { + subscriptions[index]?.release(); + } catch { + // One adapter listener cleanup cannot escape service disposal. + } + } + activation?.dispose(); + }, + handleGptEvent, + prepareProjectionSlots: ( + owner: NavigationSession, + slots: readonly string[] + ): PreparedProjectionSlots | undefined => { + if (!owner.isCurrent() || !Array.isArray(slots)) return undefined; + const copied = Object.freeze([...slots]); + let committedRecords: readonly SlotRecord[] | undefined; + return Object.freeze({ + ownerGeneration: owner.generation, + commit: (): boolean => { + if (committedRecords) return false; + const result = register( + owner, + copied.map((registeredSlotId) => ({ registeredSlotId, source: 'server' as const })) + ); + if (!result.ok) return false; + committedRecords = result.records; + return true; + }, + rollback: (): void => { + if (!committedRecords) return; + const state = mapValue(navigationStates, owner.generation); + if (!state) return; + for (const view of committedRecords) { + const record = mapValue(state.records, view.registeredSlotId); + if (!record || record.view !== view) continue; + deleteMapValue(registeredSlots, view.registeredSlotId); + deleteMapValue(state.records, view.registeredSlotId); + } + committedRecords = undefined; + }, + }); + }, + projectionRegistry: (owner: NavigationSession): ProjectionSlotRegistry => + Object.freeze({ + prepareProjectionSlots: ( + ownerGeneration: object, + slots: readonly string[], + maximumActiveSlots: number + ) => { + if ( + ownerGeneration !== owner.generation || + maximumActiveSlots !== MAX_ACTIVE_SLOT_RECORDS + ) { + return undefined; + } + return service.prepareProjectionSlots(owner, slots); + }, + }), + recordPublisherIntent: (slot: object): boolean => { + const physical = weakMapValue(physicalByObject, slot); + if (!physical || physical.state !== 'live' || physical.activeCycle) return false; + if (physical.record?.activeIntent) { + settle(physical.record.activeIntent, failed('cycle_unattributable')); + } + if (physical.record?.queuedIntent) { + settle(physical.record.queuedIntent, failed('cycle_unattributable')); + } + physical.publisherIntent = true; + return true; + }, + registeredSlotIdsForTest: (): readonly string[] => { + const records = [...mapValues(registeredSlots)]; + records.sort((left, right) => left.view.ordinal - right.view.ordinal); + return Object.freeze(records.map(({ view }) => view.registeredSlotId)); + }, + register, + request, + requestBatch, + resolveAdUnitCode: (adUnitCode: string) => resolveUnique(adUnitCodes, adUnitCode), + resolveDomAlias: (alias: string) => resolveUnique(domAliases, alias), + resolveRegisteredSlot: (registeredSlotId: string) => + mapValue(registeredSlots, registeredSlotId)?.view, + snapshotForTest: () => { + let cycles = 0; + let intents = 0; + for (const physical of setValues(physicalSlots)) { + if (physical.activeCycle) cycles += 1; + } + for (const state of mapValues(navigationStates)) { + for (const record of mapValues(state.records)) { + if (record.activeIntent) intents += 1; + if (record.queuedIntent) intents += 1; + } + } + return Object.freeze({ + cycles, + intents, + physicalSlots: setSize(physicalSlots), + records: mapSize(registeredSlots), + }); + }, + }); + + return service; +} diff --git a/crates/trusted-server-js/lib/src/services/targeting.ts b/crates/trusted-server-js/lib/src/services/targeting.ts new file mode 100644 index 000000000..241a4f3a7 --- /dev/null +++ b/crates/trusted-server-js/lib/src/services/targeting.ts @@ -0,0 +1,405 @@ +/** The narrow GPT slot targeting surface consumed by the journal. */ +export interface TargetingBoundary { + readonly clearTargeting: (key?: string) => unknown; + readonly getTargeting: (key: string) => readonly string[]; + readonly setTargeting: (key: string, value: string | readonly string[]) => unknown; +} + +/** One opaque targeting ownership frame. */ +export interface TargetingOwnership { + readonly ownerId: string; + readonly release: () => void; +} + +/** Frozen ownership inventory exposed only to tests. */ +export interface TargetingInventorySnapshot { + readonly frames: number; + readonly slots: number; +} + +/** Owner-aware targeting operations exposed to render services. */ +export interface TargetingService { + readonly dispose: () => void; + readonly disposeOwner: (ownerId: string) => void; + readonly invalidatePublisherMutation: (slot: object, key?: string) => void; + readonly observePublisherMutations: ( + slot: object, + adapter: GoogletagAdapter + ) => GoogletagOperation; + readonly own: ( + slot: object, + key: string, + value: string, + ownerId: string, + targeting: TargetingBoundary + ) => TargetingOwnership | undefined; + readonly snapshotForTest: () => TargetingInventorySnapshot; +} + +interface PublisherPredecessor { + readonly kind: 'publisher'; + readonly values: readonly string[]; +} + +interface TargetingFrame { + alive: boolean; + readonly kind: 'frame'; + predecessor: PublisherPredecessor | TargetingFrame; + readonly boundary: TargetingBoundary; + readonly installed: string; + readonly key: string; + readonly ownerId: string; + readonly slot: object; +} + +interface TargetingChain { + readonly frames: TargetingFrame[]; +} + +const mapDeleteIntrinsic = Map.prototype.delete; +const mapGetIntrinsic = Map.prototype.get; +const mapSetIntrinsic = Map.prototype.set; +const mapSizeGetter = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.get as ( + this: Map +) => number; +const weakMapDeleteIntrinsic = WeakMap.prototype.delete; +const weakMapGetIntrinsic = WeakMap.prototype.get; +const weakMapSetIntrinsic = WeakMap.prototype.set; + +function mapValue(map: Map, key: Key): Value | undefined { + return Reflect.apply(mapGetIntrinsic, map, [key]) as Value | undefined; +} + +function setMapValue(map: Map, key: Key, value: Value): void { + Reflect.apply(mapSetIntrinsic, map, [key, value]); +} + +function deleteMapValue(map: Map, key: Key): boolean { + return Reflect.apply(mapDeleteIntrinsic, map, [key]) as boolean; +} + +function mapSize(map: Map): number { + return Reflect.apply(mapSizeGetter, map, []) as number; +} + +function weakMapValue( + map: WeakMap, + key: Key +): Value | undefined { + return Reflect.apply(weakMapGetIntrinsic, map, [key]) as Value | undefined; +} + +function setWeakMapValue( + map: WeakMap, + key: Key, + value: Value +): void { + Reflect.apply(weakMapSetIntrinsic, map, [key, value]); +} + +function deleteWeakMapValue( + map: WeakMap, + key: Key +): boolean { + return Reflect.apply(weakMapDeleteIntrinsic, map, [key]) as boolean; +} + +function exactInstalledValue(values: readonly string[], installed: string): boolean { + return values.length === 1 && values[0] === installed; +} + +function copyValues(values: readonly string[]): readonly string[] { + if (!Array.isArray(values)) { + throw new TypeError('GPT targeting values must be strings'); + } + const copied: string[] = []; + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (typeof value !== 'string') { + throw new TypeError('GPT targeting values must be strings'); + } + copied[index] = value; + } + return Object.freeze(copied); +} + +/** Construct the runtime-owned GPT targeting restoration journal. */ +export function createTargetingService(): TargetingService { + const chainsBySlot = new WeakMap>(); + const liveFrames = new Set(); + const observationReleases = new Set<() => void>(); + const setAddIntrinsic = Set.prototype.add; + const setDeleteIntrinsic = Set.prototype.delete; + const setValuesIntrinsic = Set.prototype.values; + let disposed = false; + let frameCount = 0; + let slotCount = 0; + + const addLiveFrame = (frame: TargetingFrame): void => { + Reflect.apply(setAddIntrinsic, liveFrames, [frame]); + }; + const deleteLiveFrame = (frame: TargetingFrame): boolean => + Reflect.apply(setDeleteIntrinsic, liveFrames, [frame]) as boolean; + const liveFrameValues = (): IterableIterator => + Reflect.apply(setValuesIntrinsic, liveFrames, []) as IterableIterator; + const addObservationRelease = (release: () => void): void => { + Reflect.apply(setAddIntrinsic, observationReleases, [release]); + }; + const deleteObservationRelease = (release: () => void): boolean => + Reflect.apply(setDeleteIntrinsic, observationReleases, [release]) as boolean; + const observationValues = (): IterableIterator<() => void> => + Reflect.apply(setValuesIntrinsic, observationReleases, []) as IterableIterator<() => void>; + + const removeEmptySlot = (slot: object, slotChains: Map): void => { + if (mapSize(slotChains) !== 0) return; + if (weakMapValue(chainsBySlot, slot) !== slotChains) return; + if (deleteWeakMapValue(chainsBySlot, slot)) slotCount -= 1; + }; + + const invalidateChain = ( + slot: object, + slotChains: Map, + key: string, + chain: TargetingChain + ): void => { + if (mapValue(slotChains, key) !== chain) return; + deleteMapValue(slotChains, key); + for (let index = 0; index < chain.frames.length; index += 1) { + const frame = chain.frames[index]; + if (!frame?.alive) continue; + frame.alive = false; + frameCount -= 1; + deleteLiveFrame(frame); + } + chain.frames.length = 0; + removeEmptySlot(slot, slotChains); + }; + + const release = (frame: TargetingFrame): void => { + if (!frame.alive) return; + const slotChains = weakMapValue(chainsBySlot, frame.slot); + const chain = slotChains ? mapValue(slotChains, frame.key) : undefined; + if (!slotChains || !chain) { + frame.alive = false; + frameCount -= 1; + deleteLiveFrame(frame); + return; + } + let frameIndex = -1; + for (let index = 0; index < chain.frames.length; index += 1) { + if (chain.frames[index] === frame) { + frameIndex = index; + break; + } + } + if (frameIndex < 0) { + frame.alive = false; + frameCount -= 1; + deleteLiveFrame(frame); + return; + } + + const wasTop = frameIndex === chain.frames.length - 1; + const successor = chain.frames[frameIndex + 1]; + if (successor) successor.predecessor = frame.predecessor; + for (let index = frameIndex; index < chain.frames.length - 1; index += 1) { + const next = chain.frames[index + 1]; + if (next) chain.frames[index] = next; + } + chain.frames.length -= 1; + frame.alive = false; + frameCount -= 1; + deleteLiveFrame(frame); + + if (!wasTop) return; + if (chain.frames.length === 0) deleteMapValue(slotChains, frame.key); + removeEmptySlot(frame.slot, slotChains); + + let actual: readonly string[]; + try { + actual = copyValues(frame.boundary.getTargeting(frame.key)); + } catch { + return; + } + if (!exactInstalledValue(actual, frame.installed)) { + if (chain.frames.length > 0) invalidateChain(frame.slot, slotChains, frame.key, chain); + return; + } + + const predecessor = frame.predecessor; + try { + if (predecessor.kind === 'publisher') { + if (predecessor.values.length === 0) frame.boundary.clearTargeting(frame.key); + else frame.boundary.setTargeting(frame.key, predecessor.values); + } else if (predecessor.alive) { + frame.boundary.setTargeting(frame.key, predecessor.installed); + } + } catch { + // Restoration is compare-checked and best-effort; ownership is still released exactly once. + } + }; + + const invalidatePublisherMutation = (slot: object, key?: string): void => { + const slotChains = weakMapValue(chainsBySlot, slot); + if (!slotChains) return; + if (key !== undefined) { + const chain = mapValue(slotChains, key); + if (chain) invalidateChain(slot, slotChains, key, chain); + return; + } + const entries: Array = []; + const mapEntriesIntrinsic = Map.prototype.entries; + const iterator = Reflect.apply(mapEntriesIntrinsic, slotChains, []) as IterableIterator< + [string, TargetingChain] + >; + for (const entry of iterator) entries[entries.length] = entry; + for (const [entryKey, chain] of entries) invalidateChain(slot, slotChains, entryKey, chain); + }; + + const own = ( + slot: object, + key: string, + value: string, + ownerId: string, + targeting: TargetingBoundary + ): TargetingOwnership | undefined => { + if (disposed) return undefined; + if ((typeof slot !== 'object' || slot === null) && typeof slot !== 'function') { + throw new TypeError('GPT slot object required'); + } + if (key.length === 0 || value.length === 0 || ownerId.length === 0) { + throw new TypeError('Targeting key, value, and owner are required'); + } + + const actual = copyValues(targeting.getTargeting(key)); + let slotChains = weakMapValue(chainsBySlot, slot); + let chain = slotChains ? mapValue(slotChains, key) : undefined; + const top = chain?.frames[chain.frames.length - 1]; + if (top && !exactInstalledValue(actual, top.installed)) { + invalidateChain( + slot, + slotChains as Map, + key, + chain as TargetingChain + ); + slotChains = weakMapValue(chainsBySlot, slot); + chain = undefined; + } + if (!slotChains) slotChains = new Map(); + if (!chain) chain = { frames: [] }; + const currentTop = chain.frames[chain.frames.length - 1]; + const frame: TargetingFrame = { + alive: true, + kind: 'frame', + predecessor: currentTop ?? { kind: 'publisher', values: actual }, + boundary: targeting, + installed: value, + key, + ownerId, + slot, + }; + const wasNewSlot = weakMapValue(chainsBySlot, slot) === undefined; + const wasNewChain = mapValue(slotChains, key) === undefined; + let publishedWeakMap = false; + let publishedChain = false; + let publishedFrame = false; + try { + if (wasNewSlot) { + setWeakMapValue(chainsBySlot, slot, slotChains); + publishedWeakMap = true; + slotCount += 1; + } + if (wasNewChain) { + setMapValue(slotChains, key, chain); + publishedChain = true; + } + chain.frames[chain.frames.length] = frame; + publishedFrame = true; + addLiveFrame(frame); + frameCount += 1; + targeting.setTargeting(key, value); + } catch (error) { + if (publishedFrame) chain.frames.length -= 1; + frame.alive = false; + if (deleteLiveFrame(frame) && frameCount > 0) frameCount -= 1; + if (publishedChain && chain.frames.length === 0) deleteMapValue(slotChains, key); + if (publishedWeakMap && mapSize(slotChains) === 0) { + deleteWeakMapValue(chainsBySlot, slot); + if (slotCount > 0) slotCount -= 1; + } + throw error; + } + + let released = false; + return Object.freeze({ + ownerId, + release: (): void => { + if (released) return; + released = true; + release(frame); + }, + }); + }; + + return Object.freeze({ + dispose: (): void => { + if (disposed) return; + disposed = true; + const frames = [...liveFrameValues()]; + for (let index = frames.length - 1; index >= 0; index -= 1) { + const frame = frames[index]; + if (frame) release(frame); + } + const observations = [...observationValues()]; + for (let index = observations.length - 1; index >= 0; index -= 1) { + try { + observations[index]?.(); + } catch { + // Adapter wrapper cleanup cannot escape service disposal. + } + } + }, + disposeOwner: (ownerId: string): void => { + const frames = [...liveFrameValues()]; + for (let index = frames.length - 1; index >= 0; index -= 1) { + const frame = frames[index]; + if (frame?.ownerId === ownerId) release(frame); + } + }, + invalidatePublisherMutation, + observePublisherMutations: (slot: object, adapter: GoogletagAdapter) => { + const operation = adapter.run((gpt) => { + if (disposed) return; + let release = gpt.observeTargeting( + slot, + Object.freeze({ + beforePublisherMutation: (mutatedSlot: object, key?: string) => { + invalidatePublisherMutation(mutatedSlot, key); + }, + }) + ); + let active = true; + const ownedRelease = (): void => { + if (!active) return; + active = false; + deleteObservationRelease(ownedRelease); + const current = release; + release = (): void => undefined; + current(); + }; + try { + addObservationRelease(ownedRelease); + } catch (error) { + ownedRelease(); + throw error; + } + if (disposed) ownedRelease(); + }); + void operation.result.catch(() => undefined); + return operation; + }, + own, + snapshotForTest: () => Object.freeze({ frames: frameCount, slots: slotCount }), + }); +} +import type { GoogletagAdapter, GoogletagOperation } from '../adapters/googletag'; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 86a690b50..114bd8b9e 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -4,6 +4,7 @@ import { createNoopGoogletagAdapter, type GoogletagAdapter, type GoogletagBindingStatus, + type GoogletagFacade, } from '../../src/adapters/googletag'; import { createNoopMessagingAdapter, @@ -234,6 +235,74 @@ describe('browser composition', () => { expect(Object.isFrozen(composition.runtime)).toBe(true); }); + it('subscribes the injected slot service before correctness activation and disposes both listeners', async () => { + 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(), + 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: 'a'.repeat(64), + manifest: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { + bridgeRecognizer: vi.fn(() => { + expect(subscriptions).toEqual([]); + }), + correctnessGptListeners: correctness, + }, + } + ); + + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(correctness).toHaveBeenCalledOnce(); + composition.runtime.dispose(); + expect(releases).toEqual(['slotRenderEnded', 'slotRequested']); + }); + it('constructs one session lazily from accepted boot and keeps it across SPA replacement', async () => { const projection = { version: 1, @@ -290,6 +359,13 @@ describe('browser composition', () => { const session = composition.runtimeSessionForTest(); expect(session).toBeDefined(); expect(composition.runtimeSessionForTest()).toBe(session); + const slotService = composition.slotServiceForTest(); + const targetingService = composition.targetingServiceForTest(); + expect(slotService).toBeDefined(); + expect(targetingService).toBeDefined(); + expect(session?.interfaces['slots']).toBe(slotService); + expect(session?.interfaces['targeting']).toBe(targetingService); + expect(session?.currentNavigation?.interfaces).toBe(session?.interfaces); expect(session?.currentNavigation?.currentAuctionProjection).toEqual(projection); expect(Object.isFrozen(session?.currentNavigation?.currentAuctionProjection)).toBe(true); @@ -323,6 +399,15 @@ describe('browser composition', () => { 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(composition.slotServiceForTest()).toBeUndefined(); + expect(composition.targetingServiceForTest()).toBeUndefined(); }); it('unwinds a lazily-created session when navigation identity generation fails', async () => { @@ -463,6 +548,58 @@ describe('browser composition', () => { 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: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, + 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}`, + })), + }, + 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: { + bridgeRecognizer: vi.fn(), + 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( 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..a445a13d1 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -0,0 +1,1375 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createBrowserGoogletagAdapter, + type GoogletagAdapter, + type GoogletagFacade, + type GoogletagReplacementDefinition, +} from '../../src/adapters/googletag'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { createRuntimeSession, type NavigationSession } from '../../src/kernel/sessions'; +import { + MAX_ACTIVE_SLOT_RECORDS, + createSlotService, + type GptSlotBinding, + 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; + missingRefresh?: 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 facade: GoogletagFacade = Object.freeze({ + bindingToken: () => bindingToken, + clearTargeting: vi.fn(), + display, + getTargeting: vi.fn(() => []), + observeTargeting: () => vi.fn(), + refresh: options.missingRefresh + ? (undefined as unknown as GoogletagFacade['refresh']) + : refresh, + serviceState: () => + Object.freeze({ + apiReady: true, + initialLoadDisabled: options.initialLoadDisabled === true, + pubadsReady: true, + }), + setTargeting: vi.fn(), + 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 + ) => { + if (!destroySlots([oldSlot])) return undefined; + if (!definition || !isCurrent()) return undefined; + const replacement = defineSlot(definition.adUnitPath, definition.sizes, definition.elementId); + if (!replacement) return undefined; + if (!isCurrent()) { + destroySlots([replacement]); + return undefined; + } + addService(replacement); + if (!isCurrent()) { + destroySlots([replacement]); + return undefined; + } + return replacement; + }, + }); + const adapter: GoogletagAdapter = Object.freeze({ + bindingStatus: () => 'present', + dispose: vi.fn(), + notifyReady: vi.fn(), + run: (command: (gpt: Readonly) => T) => { + let disposed = false; + const dispose = vi.fn(() => { + disposed = true; + }); + operationDisposals.push(dispose); + let result: Promise; + if (options.synchronousRun) { + try { + result = Promise.resolve(command(facade)); + } 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, + }; +} + +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; +} + +describe('slot registry', () => { + afterEach(() => vi.useRealTimers()); + + it('accepts exact nonempty 256-byte ids and rejects empty, 257-byte, NUL, and 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', + `c1${String.fromCharCode(0x85)}`, + ]) { + expect(service.register(navigation, [serverRegistration(invalid)])).toEqual({ + ok: false, + reason: 'invalid_slot_id', + }); + } + }); + + 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('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('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, + }); + }); +}); + +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]]), + }); + + it.each(['throw', 'false', 'define'] 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); + } else { + harness.defineSlot.mockReturnValue(undefined); + } + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace({}, definition, () => true) + ); + + await expect(operation.result).rejects.toBeDefined(); + expect(harness.defineSlot).toHaveBeenCalledTimes(failure === 'define' ? 1 : 0); + 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; + }) + ); + + await expect(operation.result).resolves.toBeUndefined(); + 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; + }) + ); + + await expect(operation.result).rejects.toBeDefined(); + expect(harness.destroySlots).toHaveBeenCalledTimes(2); + }); +}); + +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('retries after readiness timeout and never duplicates listeners on the recovered binding', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const service = createSlotService({ googletag: adapter }); + const missing = service.activate(); + 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.activate().result).resolves.toBeUndefined(); + await expect(service.activate().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 }); + await expect(service.activate().result).resolves.toBeUndefined(); + target.googletag = second.binding; + await expect(service.activate().result).resolves.toBeUndefined(); + await expect(service.activate().result).resolves.toBeUndefined(); + + expect(first.addEventListener).toHaveBeenCalledTimes(2); + expect(second.addEventListener).toHaveBeenCalledTimes(2); + service.dispose(); + expect(first.removeEventListener).toHaveBeenCalledTimes(2); + expect(second.removeEventListener).toHaveBeenCalledTimes(2); + }); +}); + +describe('physical GPT cycles', () => { + afterEach(() => vi.useRealTimers()); + + 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('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', + }); + active.dispose(); + await expect(active.result).resolves.toEqual({ + reason: 'superseded', + status: 'cancelled', + }); + }); + + 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[0]).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('keeps a completion-timeout cycle quarantined until its exact late completion drains', 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: 'completion-timeout', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + await vi.advanceTimersByTimeAsync(10_000); + await expect(first.result).resolves.toMatchObject({ reason: 'gpt_completion_timeout' }); + const blocked = service.request({ + intentId: 'blocked', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await expect(blocked.result).resolves.toMatchObject({ reason: 'slot_quarantined' }); + + service.handleGptEvent('slotRequested', { slot }); + expect(service.snapshotForTest().cycles).toBe(1); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'late-completion', + slot, + }); + const recovered = service.request({ + intentId: 'recovered', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + expect(recovered.status).toBe('active'); + recovered.dispose(); + }); + + 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', 'define'] 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 if (failure === 'false') { + harness.destroySlots.mockReturnValue(false); + } else { + harness.defineSlot.mockReturnValue(undefined); + } + 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).toHaveBeenCalledTimes(failure === 'define' ? 1 : 0); + 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.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 }); + }); +}); 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..09f3a8d07 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/targeting.test.ts @@ -0,0 +1,316 @@ +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: 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); + }); +}); + +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('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); + }); +}); From e928eaa22259e34d582663705b3da884daed6bbb Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:43:54 -0700 Subject: [PATCH 030/194] Harden slot ownership and GPT recovery transactions --- .../lib/src/adapters/googletag.ts | 150 ++- .../lib/src/services/slots.ts | 674 +++++++++-- .../lib/src/services/targeting.ts | 189 ++- .../lib/test/services/slots.test.ts | 1045 ++++++++++++++++- .../lib/test/services/targeting.test.ts | 249 ++++ ...s-render-fix-and-tsjs-resilience-design.md | 6 +- 6 files changed, 2116 insertions(+), 197 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index 253bd39ac..fa5fb7500 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -33,6 +33,33 @@ export interface GoogletagReplacementDefinition { readonly sizes: unknown; } +/** Reversible synchronous admission for one newly defined GPT identity. */ +export interface GoogletagReplacementCommitAdmission { + commit(): boolean; + rollback(): void; +} + +/** 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 constructor(orphanedSlot?: object, oldSlotDestroyed = false, cause?: unknown) { + super('gpt_replacement_failed'); + this.name = 'GoogletagReplacementError'; + this.orphanedSlot = orphanedSlot; + this.oldSlotDestroyed = oldSlotDestroyed; + this.cause = cause; + } +} + /** Observer called before a publisher-originated targeting mutation is forwarded. */ export interface GoogletagTargetingObserver { readonly beforePublisherMutation: (slot: object, key?: string) => void; @@ -57,8 +84,9 @@ export interface GoogletagFacade { transactionalReplace( oldSlot: object, definition: GoogletagReplacementDefinition | undefined, - isGenerationCurrent: () => boolean - ): object | undefined; + isGenerationCurrent: () => boolean, + prepareCommit: (replacement: object) => GoogletagReplacementCommitAdmission + ): GoogletagReplacementResult; } /** Options owned by one GPT operation. */ @@ -151,6 +179,9 @@ const setSizeGetter = Object.getOwnPropertyDescriptor(Set.prototype, 'size')?.ge 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; @@ -180,6 +211,16 @@ 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; } @@ -327,7 +368,7 @@ function createFacade( if (!isOperationCurrent()) return undefined; const original = member(slot, key); let descriptor: PropertyDescriptor | undefined; - let installed = false; + let defineAttempted = false; const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { if ((weakMapValue(targetingWrites, slot) ?? 0) === 0) { try { @@ -340,8 +381,8 @@ function createFacade( return Reflect.apply(original, this, arguments_); }; const restore = (): void => { - if (!installed) return; - installed = false; + if (!defineAttempted) return; + defineAttempted = false; try { const current = Object.getOwnPropertyDescriptor(slot, key); if (!current || current.value !== wrapper) return; @@ -363,10 +404,14 @@ function createFacade( const replacement = descriptor ? { ...descriptor, value: wrapper } : { configurable: true, enumerable: true, value: wrapper, writable: true }; - if (!isOperationCurrent() || !Reflect.defineProperty(slot, key, replacement)) { + if (!isOperationCurrent()) { + return undefined; + } + defineAttempted = true; + if (!Reflect.defineProperty(slot, key, replacement)) { + restore(); return undefined; } - installed = true; if (!isOperationCurrent() || safeMember(slot, key) !== wrapper) { restore(); return undefined; @@ -403,7 +448,10 @@ function createFacade( const observers = new Set(); const dispatcher: GoogletagTargetingObserver = Object.freeze({ beforePublisherMutation: (mutatedSlot: object, key?: string): void => { - for (const current of setValues(observers)) { + 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 { @@ -546,9 +594,14 @@ function createFacade( transactionalReplace: ( oldSlot: object, definition: GoogletagReplacementDefinition | undefined, - isGenerationCurrent: () => boolean - ): object | undefined => { - if (typeof isGenerationCurrent !== 'function' || !isOperationCurrent()) { + 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 => { @@ -558,11 +611,20 @@ function createFacade( return false; } }; - if (!destroy(oldSlot)) throw new Error('gpt_request_failed'); + 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); if (definition === undefined || !isGenerationCurrent() || !isOperationCurrent()) { - return undefined; + return destroyed; } let replacement: object | undefined; + let admission: GoogletagReplacementCommitAdmission | undefined; + let commitAttempted = false; try { const candidate = call(binding.binding, 'defineSlot', [ definition.adUnitPath, @@ -573,25 +635,67 @@ function createFacade( (typeof candidate !== 'object' || candidate === null) && typeof candidate !== 'function' ) { - throw new Error('gpt_request_failed'); + 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; + const stale = replacement as object; replacement = undefined; - if (!destroy(stale)) throw new Error('gpt_request_failed'); - return undefined; + if (!destroy(stale)) throw new GoogletagReplacementError(stale, true); + return destroyed; + } + 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; + } + admission = prepareCommit(replacement as object); + if ( + !admission || + typeof admission.commit !== 'function' || + typeof admission.rollback !== 'function' + ) { + throw new GoogletagReplacementError(undefined, true); } - call(replacement, 'addService', [service()]); + commitAttempted = true; + if (!admission.commit()) throw new GoogletagReplacementError(undefined, true); if (!isGenerationCurrent() || !isOperationCurrent()) { - const stale = replacement; + 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 Error('gpt_request_failed'); - return undefined; + if (!destroy(stale)) { + throw new GoogletagReplacementError(stale, true, rollbackFailure); + } + if (rollbackFailed) { + throw new GoogletagReplacementError(undefined, true, rollbackFailure); + } + return destroyed; } - return replacement; + return Object.freeze({ status: 'replaced' as const, slot: replacement as object }); } catch (error) { - if (replacement) destroy(replacement); + if (commitAttempted) { + try { + admission?.rollback(); + } catch { + // Candidate cleanup remains mandatory even when service rollback is hostile. + } + } + if (replacement) cleanup(replacement, error); throw error; } }, diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 993aaf109..234f74f4a 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -2,8 +2,11 @@ import type { GoogletagAdapter, GoogletagFacade, GoogletagOperation, + GoogletagReplacementCommitAdmission, GoogletagReplacementDefinition, + GoogletagReplacementResult, } from '../adapters/googletag'; +import { GoogletagReplacementError } from '../adapters/googletag'; import type { NavigationSession } from '../kernel/sessions'; import type { PreparedProjectionSlots, ProjectionSlotRegistry } from './projections'; @@ -13,6 +16,9 @@ export const MAX_ACTIVE_SLOT_RECORDS = 256; const GPT_REQUEST_START_TIMEOUT_MS = 3_000; const GPT_COMPLETION_TIMEOUT_MS = 10_000; +const MAX_PENDING_PUBLISHER_INTENTS = 64; +const MAX_SLOT_ALIASES = 256; +const MAX_PLACEMENT_QUARANTINE_KEYS = 2_048; export type SlotSource = 'programmatic' | 'server'; export type GptSlotOwnership = 'publisher' | 'trusted_server'; @@ -36,7 +42,7 @@ export interface SlotRecord { } export type SlotRegistrationFailure = - 'duplicate_slot' | 'invalid_slot_id' | 'registry_capacity' | 'stale_owner'; + 'duplicate_slot' | 'invalid_slot_id' | 'registry_capacity' | 'slot_quarantined' | 'stale_owner'; export type SlotRegistrationResult = | Readonly<{ ok: true; records: readonly SlotRecord[] }> @@ -116,6 +122,7 @@ export interface SlotService { slots: readonly string[] ) => PreparedProjectionSlots | undefined; readonly projectionRegistry: (owner: NavigationSession) => ProjectionSlotRegistry; + readonly recordPublisherDestruction: (slot: object) => boolean; readonly recordPublisherIntent: (slot: object) => boolean; readonly registeredSlotIdsForTest: () => readonly string[]; readonly register: ( @@ -162,18 +169,22 @@ interface PhysicalSlot { definition: GoogletagReplacementDefinition | undefined; lastResponseIdentifier: string | undefined; ownership: GptSlotOwnership; - publisherIntent: boolean; + placementKeys: readonly string[]; + publisherIntentCount: number; quarantineReason: 'completion' | 'navigation' | 'request' | undefined; record: InternalSlotRecord | undefined; readonly slot: object; state: PhysicalSlotState; + destroyAttempted: boolean; } interface RequestIntent { completionTimer: ReturnType | undefined; readonly input: SlotRequestInput; - invocation: GoogletagOperation | undefined; + invocation: { dispose(): void } | undefined; requestStartedAt: number | undefined; + requestDeadlineAt: number | undefined; + completionDeadlineAt: number | undefined; requestTimer: ReturnType | undefined; readonly resolve: (outcome: SlotRequestOutcome) => void; readonly result: Promise; @@ -196,6 +207,9 @@ const mapDeleteIntrinsic = Map.prototype.delete; const mapGetIntrinsic = Map.prototype.get; const mapSetIntrinsic = Map.prototype.set; const mapValuesIntrinsic = Map.prototype.values; +const mapIteratorNextIntrinsic = Object.getPrototypeOf(new Map().values()).next as ( + this: IterableIterator +) => IteratorResult; const mapSizeGetter = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.get as ( this: Map ) => number; @@ -203,6 +217,9 @@ const setAddIntrinsic = Set.prototype.add; const setDeleteIntrinsic = Set.prototype.delete; const setHasIntrinsic = Set.prototype.has; const setValuesIntrinsic = Set.prototype.values; +const setIteratorNextIntrinsic = Object.getPrototypeOf(new Set().values()).next as ( + this: IterableIterator +) => IteratorResult; const setSizeGetter = Object.getOwnPropertyDescriptor(Set.prototype, 'size')?.get as ( this: Set ) => number; @@ -226,6 +243,16 @@ function mapValues(map: Map): IterableIterator { return Reflect.apply(mapValuesIntrinsic, map, []) as IterableIterator; } +function mapValueSnapshot(map: Map): Value[] { + const iterator = mapValues(map); + const values: Value[] = []; + while (true) { + const step = Reflect.apply(mapIteratorNextIntrinsic, iterator, []) as IteratorResult; + if (step.done) return values; + values[values.length] = step.value; + } +} + function mapSize(map: Map): number { return Reflect.apply(mapSizeGetter, map, []) as number; } @@ -246,6 +273,16 @@ function setValues(set: Set): IterableIterator { return Reflect.apply(setValuesIntrinsic, set, []) as IterableIterator; } +function setValueSnapshot(set: Set): Value[] { + const iterator = setValues(set); + const values: Value[] = []; + 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; } @@ -280,7 +317,12 @@ function setIndexValue( let records = mapValue(index, key); if (!records) { records = new Set(); - setMapValue(index, key, records); + try { + setMapValue(index, key, records); + } catch (error) { + if (mapValue(index, key) === records) deleteMapValue(index, key); + throw error; + } } try { addSetValue(records, record); @@ -308,19 +350,26 @@ function resolveUnique( const records = mapValue(index, key); if (!records || setSize(records) !== 1) return undefined; const iterator = setValues(records); - const first = iterator.next(); + const first = Reflect.apply( + setIteratorNextIntrinsic, + iterator, + [] + ) as IteratorResult; return first.done ? undefined : first.value.view; } function validSlotIdentity(value: string): boolean { return ( - value.length > 0 && new TextEncoder().encode(value).length <= 256 && !/[\p{Cc}]/u.test(value) + value.length > 0 && + new TextEncoder().encode(value).length <= 256 && + !/[\p{Cc}]/u.test(value) && + !/[\uD800-\uDFFF]/u.test(value) ); } function frozenAliases(aliases: readonly string[] | undefined): readonly string[] | undefined { if (aliases === undefined) return Object.freeze([]); - if (!Array.isArray(aliases)) return undefined; + if (!Array.isArray(aliases) || aliases.length > MAX_SLOT_ALIASES) return undefined; const output: string[] = []; const seen = new Set(); for (const alias of aliases) { @@ -348,6 +397,25 @@ const failed = (reason: SlotRequestFailure): SlotRequestOutcome => const cancelled = (reason: 'navigation_disposed' | 'superseded'): SlotRequestOutcome => Object.freeze({ status: 'cancelled' as const, reason }); +function placementKeysFor( + registeredSlotId: string, + adUnitCode: string | undefined, + aliases: readonly string[], + definition?: GoogletagReplacementDefinition +): readonly string[] { + const keys: string[] = [`registered:${registeredSlotId}`]; + if (adUnitCode !== undefined) keys[keys.length] = `ad-unit:${adUnitCode}`; + for (let index = 0; index < aliases.length; index += 1) { + const alias = aliases[index]; + if (alias !== undefined) keys[keys.length] = `dom:${alias}`; + } + if (definition) { + keys[keys.length] = `path:${definition.adUnitPath}`; + keys[keys.length] = `dom:${definition.elementId}`; + } + return Object.freeze(keys); +} + /** Construct the document-lifetime slot registry and physical GPT cycle service. */ export function createSlotService(options: SlotServiceOptions): SlotService { const navigationStates = new Map(); @@ -356,13 +424,72 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const domAliases = new Map>(); const physicalByObject = new WeakMap(); const physicalSlots = new Set(); - const now = options.now ?? (() => Date.now()); + const placementQuarantine = new Map(); + const quarantinedKeysByPhysical = new WeakMap(); + const now = options.now ?? (() => performance.now()); + let placementQuarantineSaturated = false; let disposed = false; let deferInvocations = false; let activation: GoogletagOperation | undefined; const subscriptionsByBinding = new WeakMap(); const bindingSubscriptions = new Set(); + const hasPlacementQuarantine = (keys: readonly string[]): boolean => { + if (placementQuarantineSaturated) return true; + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (key !== undefined && mapValue(placementQuarantine, key) !== undefined) return true; + } + return false; + }; + + const quarantinePhysicalPlacement = (physical: PhysicalSlot): void => { + if (weakMapValue(quarantinedKeysByPhysical, physical.slot)) return; + let additionalKeys = 0; + for (let index = 0; index < physical.placementKeys.length; index += 1) { + const key = physical.placementKeys[index]; + if (key !== undefined && mapValue(placementQuarantine, key) === undefined) + additionalKeys += 1; + } + if (mapSize(placementQuarantine) + additionalKeys > MAX_PLACEMENT_QUARANTINE_KEYS) { + placementQuarantineSaturated = true; + return; + } + try { + setWeakMapValue(quarantinedKeysByPhysical, physical.slot, physical.placementKeys); + if (weakMapValue(quarantinedKeysByPhysical, physical.slot) !== physical.placementKeys) { + throw new Error('quarantine publication failed'); + } + for (let index = 0; index < physical.placementKeys.length; index += 1) { + const key = physical.placementKeys[index]; + if (key === undefined) continue; + const previous = mapValue(placementQuarantine, key) ?? 0; + try { + setMapValue(placementQuarantine, key, previous + 1); + } catch (error) { + if (mapValue(placementQuarantine, key) !== previous + 1) throw error; + throw error; + } + } + } catch { + placementQuarantineSaturated = true; + } + }; + + const releasePhysicalPlacement = (physical: PhysicalSlot): void => { + const keys = weakMapValue(quarantinedKeysByPhysical, physical.slot); + if (!keys) return; + deleteWeakMapValue(quarantinedKeysByPhysical, physical.slot); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (key === undefined) continue; + const count = mapValue(placementQuarantine, key); + if (count === undefined) continue; + if (count <= 1) deleteMapValue(placementQuarantine, key); + else setMapValue(placementQuarantine, key, count - 1); + } + }; + const settle = (intent: RequestIntent, outcome: SlotRequestOutcome): void => { if (intent.terminal) return; intent.terminal = true; @@ -371,6 +498,8 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (intent.completionTimer !== undefined) clearTimeout(intent.completionTimer); intent.requestTimer = undefined; intent.completionTimer = undefined; + intent.requestDeadlineAt = undefined; + intent.completionDeadlineAt = undefined; intent.invocation?.dispose(); intent.invocation = undefined; if (intent.record.activeIntent === intent) intent.record.activeIntent = undefined; @@ -395,51 +524,82 @@ export function createSlotService(options: SlotServiceOptions): SlotService { invokeIntent(record, queued); }; - const bindReplacement = ( + const prepareReplacementCommit = ( record: InternalSlotRecord, oldPhysical: PhysicalSlot, replacement: object - ): boolean => { + ): GoogletagReplacementCommitAdmission => { if ( + replacement === oldPhysical.slot || record.physical !== oldPhysical || record.state.disposed || !record.state.owner.isCurrent() ) { - return false; + throw new Error('gpt_request_failed'); } const existing = weakMapValue(physicalByObject, replacement); - if (existing && existing !== oldPhysical) return false; + if (existing) throw new Error('gpt_request_failed'); const physical: PhysicalSlot = { activeCycle: undefined, definition: oldPhysical.definition, + destroyAttempted: false, lastResponseIdentifier: undefined, ownership: 'trusted_server', - publisherIntent: false, + placementKeys: oldPhysical.placementKeys, + publisherIntentCount: 0, quarantineReason: undefined, record, slot: replacement, state: 'live', }; - try { - setWeakMapValue(physicalByObject, replacement, physical); - addSetValue(physicalSlots, physical); - if (record.state.disposed || !record.state.owner.isCurrent()) throw new Error('stale owner'); - oldPhysical.record = undefined; - record.physical = physical; - deleteSetValue(physicalSlots, oldPhysical); - return true; - } catch { + let committed = false; + const rollback = (): void => { + if (record.physical === physical) record.physical = oldPhysical; + if (oldPhysical.record === undefined && record.physical === oldPhysical) { + oldPhysical.record = record; + } deleteSetValue(physicalSlots, physical); if (weakMapValue(physicalByObject, replacement) === physical) { deleteWeakMapValue(physicalByObject, replacement); } - return false; - } + if (committed) addSetValue(physicalSlots, oldPhysical); + committed = false; + }; + return Object.freeze({ + commit: (): boolean => { + if ( + committed || + record.physical !== oldPhysical || + record.state.disposed || + !record.state.owner.isCurrent() + ) { + return false; + } + setWeakMapValue(physicalByObject, replacement, physical); + if (weakMapValue(physicalByObject, replacement) !== physical) return false; + addSetValue(physicalSlots, physical); + if (!setHasValue(physicalSlots, physical)) return false; + if (record.state.disposed || !record.state.owner.isCurrent()) return false; + record.physical = physical; + oldPhysical.record = undefined; + deleteSetValue(physicalSlots, oldPhysical); + committed = true; + return true; + }, + rollback, + }); }; const recoverRequestTimeout = (record: InternalSlotRecord, physical: PhysicalSlot): void => { + if (physical.destroyAttempted) { + physical.state = 'quarantined'; + failQueued(record, 'gpt_request_failed'); + return; + } physical.state = 'retired'; physical.quarantineReason = 'request'; + physical.destroyAttempted = true; + quarantinePhysicalPlacement(physical); if ( physical.ownership !== 'trusted_server' || !physical.definition || @@ -450,13 +610,14 @@ export function createSlotService(options: SlotServiceOptions): SlotService { failQueued(record, 'gpt_request_failed'); return; } - let operation: GoogletagOperation; + let operation: GoogletagOperation; try { operation = options.googletag.run((gpt) => gpt.transactionalReplace( physical.slot, physical.definition, - () => !record.state.disposed && record.state.owner.isCurrent() + () => !record.state.disposed && record.state.owner.isCurrent(), + (replacement) => prepareReplacementCommit(record, physical, replacement) ) ); } catch { @@ -464,17 +625,60 @@ export function createSlotService(options: SlotServiceOptions): SlotService { failQueued(record, 'gpt_request_failed'); return; } + const detachDestroyedOld = (): void => { + if (record.physical === physical) record.physical = undefined; + physical.record = undefined; + releasePhysicalPlacement(physical); + deleteSetValue(physicalSlots, physical); + if (weakMapValue(physicalByObject, physical.slot) === physical) { + deleteWeakMapValue(physicalByObject, physical.slot); + } + }; void operation.result.then( - (replacement) => { - if (!replacement || !bindReplacement(record, physical, replacement)) { - physical.state = 'quarantined'; + (result) => { + if (result.status !== 'replaced') { + detachDestroyedOld(); failQueued(record, 'gpt_request_failed'); return; } + releasePhysicalPlacement(physical); + if (weakMapValue(physicalByObject, physical.slot) === physical) { + deleteWeakMapValue(physicalByObject, physical.slot); + } + deleteSetValue(physicalSlots, physical); advanceQueued(record); }, - () => { + (error: unknown) => { physical.state = 'quarantined'; + const replacementError = error instanceof GoogletagReplacementError ? error : undefined; + const reusedOldIdentity = replacementError?.orphanedSlot === physical.slot; + if (replacementError?.oldSlotDestroyed && !reusedOldIdentity) { + detachDestroyedOld(); + } + if (replacementError?.orphanedSlot && replacementError.orphanedSlot !== physical.slot) { + const orphan: PhysicalSlot = { + activeCycle: undefined, + definition: physical.definition, + destroyAttempted: true, + lastResponseIdentifier: undefined, + ownership: 'trusted_server', + placementKeys: physical.placementKeys, + publisherIntentCount: 0, + quarantineReason: 'request', + record: undefined, + slot: replacementError.orphanedSlot, + state: 'quarantined', + }; + try { + setWeakMapValue(physicalByObject, orphan.slot, orphan); + if (weakMapValue(physicalByObject, orphan.slot) !== orphan) { + throw new Error('orphan publication failed'); + } + quarantinePhysicalPlacement(orphan); + } catch { + placementQuarantineSaturated = true; + } + } failQueued(record, 'gpt_request_failed'); } ); @@ -497,6 +701,14 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const onRequestTimeout = (intent: RequestIntent): void => { if (intent.terminal || intent.state !== 'active') return; + const deadline = intent.requestDeadlineAt; + if (deadline !== undefined && now() < deadline) { + intent.requestTimer = setTimeout( + () => onRequestTimeout(intent), + Math.max(1, deadline - now()) + ); + return; + } const physical = intent.record.physical; settle(intent, failed('gpt_request_timeout')); if (!physical) { @@ -508,6 +720,14 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const onCompletionTimeout = (intent: RequestIntent): void => { if (intent.terminal || intent.state !== 'cycle') return; + const deadline = intent.completionDeadlineAt; + if (deadline !== undefined && now() < deadline) { + intent.completionTimer = setTimeout( + () => onCompletionTimeout(intent), + Math.max(1, deadline - now()) + ); + return; + } const physical = intent.record.physical; if (physical?.activeCycle?.intent === intent) { physical.activeCycle.intent = undefined; @@ -519,9 +739,27 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const armRequestDeadline = (intent: RequestIntent): void => { intent.requestStartedAt = now(); + intent.requestDeadlineAt = intent.requestStartedAt + GPT_REQUEST_START_TIMEOUT_MS; + intent.completionDeadlineAt = intent.requestStartedAt + GPT_COMPLETION_TIMEOUT_MS; intent.requestTimer = setTimeout(() => onRequestTimeout(intent), GPT_REQUEST_START_TIMEOUT_MS); }; + const failExternalInvocation = (record: InternalSlotRecord, intent: RequestIntent): void => { + if (intent.terminal) return; + const physical = record.physical; + if (physical?.activeCycle?.intent === intent) { + physical.activeCycle.intent = undefined; + physical.state = 'quarantined'; + physical.quarantineReason = 'completion'; + settle(intent, failed('gpt_request_failed')); + return; + } + const wasInvoked = intent.requestStartedAt !== undefined; + settle(intent, failed('gpt_request_failed')); + if (wasInvoked && physical) recoverRequestTimeout(record, physical); + else advanceQueued(record); + }; + const ensureBindingSubscriptions = ( gpt: Readonly ): BindingSubscriptionAdmission => { @@ -568,7 +806,19 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return { installed: true, ownership }; }; - function invokeIntent(record: InternalSlotRecord, intent: RequestIntent): void { + const retireHistoricalSubscriptions = (current: BindingSubscriptions): void => { + const historical = setValueSnapshot(bindingSubscriptions); + for (let index = 0; index < historical.length; index += 1) { + const subscription = historical[index]; + if (subscription && subscription !== current) subscription.release(); + } + }; + + function invokeExternalIntent( + record: InternalSlotRecord, + intent: RequestIntent, + expectedBindingToken: object + ): void { if ( intent.terminal || record.activeIntent !== intent || @@ -582,14 +832,21 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (!physical || physical.state !== 'live' || physical.activeCycle) { settle( intent, - failed(physical?.state === 'quarantined' ? 'slot_quarantined' : 'slot_unresolved') + failed( + physical?.state === 'quarantined' + ? 'slot_quarantined' + : physical?.activeCycle + ? 'cycle_unattributable' + : 'slot_unresolved' + ) ); return; } - let subscriptions: BindingSubscriptionAdmission | undefined; try { const operation = options.googletag.run((gpt) => { - subscriptions = ensureBindingSubscriptions(gpt); + if (gpt.bindingToken() !== expectedBindingToken) { + throw new Error('GPT binding changed before invocation'); + } if ( intent.terminal || record.activeIntent !== intent || @@ -616,17 +873,42 @@ export function createSlotService(options: SlotServiceOptions): SlotService { void operation.result.then( () => undefined, () => { - if (subscriptions?.installed) subscriptions.ownership.release(); - if (intent.terminal) return; - settle(intent, failed('gpt_request_failed')); - advanceQueued(record); + failExternalInvocation(record, intent); } ); } catch { - if (subscriptions?.installed) subscriptions.ownership.release(); - settle(intent, failed('gpt_request_failed')); - advanceQueued(record); + failExternalInvocation(record, intent); + } + } + + function invokeIntent(record: InternalSlotRecord, intent: RequestIntent): void { + if (intent.terminal) return; + let operation: GoogletagOperation; + let provisionalSubscriptions: BindingSubscriptionAdmission | undefined; + try { + operation = options.googletag.run((gpt) => { + provisionalSubscriptions = ensureBindingSubscriptions(gpt); + return provisionalSubscriptions; + }); + } catch { + if (provisionalSubscriptions?.installed) provisionalSubscriptions.ownership.release(); + failExternalInvocation(record, intent); + return; } + intent.invocation = operation; + void operation.result.then( + (subscriptions) => { + if (intent.invocation === operation) intent.invocation = undefined; + retireHistoricalSubscriptions(subscriptions.ownership); + if (intent.terminal) return; + invokeExternalIntent(record, intent, subscriptions.ownership.token); + }, + () => { + if (intent.invocation === operation) intent.invocation = undefined; + if (provisionalSubscriptions?.installed) provisionalSubscriptions.ownership.release(); + failExternalInvocation(record, intent); + } + ); } const handleGptEvent = (type: GptEventType, event: unknown): void => { @@ -639,8 +921,8 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (physical.state !== 'live' || physical.activeCycle) return; const record = physical.record; const intent = record?.activeIntent; - if (physical.publisherIntent) { - physical.publisherIntent = false; + if (physical.publisherIntentCount > 0) { + physical.publisherIntentCount -= 1; if (intent && !intent.terminal) settle(intent, failed('cycle_unattributable')); physical.activeCycle = { intent: undefined, kind: 'publisher' }; return; @@ -651,14 +933,17 @@ export function createSlotService(options: SlotServiceOptions): SlotService { intent.state === 'active' && intent.requestStartedAt !== undefined ) { + if (now() > (intent.requestDeadlineAt ?? Number.NEGATIVE_INFINITY)) { + onRequestTimeout(intent); + return; + } if (intent.requestTimer !== undefined) clearTimeout(intent.requestTimer); intent.requestTimer = undefined; intent.state = 'cycle'; physical.activeCycle = { intent, kind: 'trusted_server' }; - const elapsed = Math.max(0, now() - intent.requestStartedAt); intent.completionTimer = setTimeout( () => onCompletionTimeout(intent), - Math.max(0, GPT_COMPLETION_TIMEOUT_MS - elapsed) + Math.max(0, (intent.completionDeadlineAt ?? now()) - now()) ); return; } @@ -678,16 +963,25 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } const cycle = physical.activeCycle; if (!cycle) return; + const intent = cycle.intent; + if ( + intent && + !intent.terminal && + now() > (intent.completionDeadlineAt ?? Number.NEGATIVE_INFINITY) + ) { + onCompletionTimeout(intent); + return; + } + const isEmptyValue = ownData(event, 'isEmpty'); + if (isEmptyValue !== true && isEmptyValue !== false) return; if (responseIdentifier !== undefined) physical.lastResponseIdentifier = responseIdentifier; physical.activeCycle = undefined; - const intent = cycle.intent; if (intent && !intent.terminal) { - const isEmpty = ownData(event, 'isEmpty') === true; settle( intent, Object.freeze({ ...(responseIdentifier === undefined ? {} : { responseIdentifier }), - status: isEmpty ? ('empty' as const) : ('rendered' as const), + status: isEmptyValue ? ('empty' as const) : ('rendered' as const), }) ); advanceQueued(intent.record); @@ -696,6 +990,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (physical.quarantineReason === 'completion' || physical.quarantineReason === 'navigation') { const quarantineReason = physical.quarantineReason; physical.quarantineReason = undefined; + if (quarantineReason === 'navigation' && physical.ownership === 'publisher') { + releasePhysicalPlacement(physical); + } if (quarantineReason === 'completion' || physical.ownership === 'publisher') { physical.state = 'live'; } @@ -710,35 +1007,50 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (physical.activeCycle) { physical.state = 'quarantined'; physical.quarantineReason = 'navigation'; + quarantinePhysicalPlacement(physical); } + deleteSetValue(physicalSlots, physical); + return; + } + if (physical.destroyAttempted) { + deleteSetValue(physicalSlots, physical); return; } - if (physical.state === 'retired') return; physical.state = 'retired'; physical.quarantineReason = 'navigation'; + physical.destroyAttempted = true; + quarantinePhysicalPlacement(physical); + deleteSetValue(physicalSlots, physical); let operation: GoogletagOperation | undefined; try { operation = options.googletag.run((gpt) => - gpt.transactionalReplace(physical.slot, undefined, () => false) + gpt.transactionalReplace( + physical.slot, + undefined, + () => false, + () => { + throw new Error('destroy-only replacement cannot commit'); + } + ) ); void operation.result.then( () => { - if (!physical.activeCycle && !physical.record) deleteSetValue(physicalSlots, physical); + releasePhysicalPlacement(physical); + if (weakMapValue(physicalByObject, physical.slot) === physical) { + deleteWeakMapValue(physicalByObject, physical.slot); + } }, - () => { - if (!physical.activeCycle && !physical.record) deleteSetValue(physicalSlots, physical); - } + () => undefined ); } catch { operation?.dispose(); - if (!physical.activeCycle && !physical.record) deleteSetValue(physicalSlots, physical); } }; const disposeNavigationState = (state: NavigationState): void => { if (state.disposed) return; state.disposed = true; - const records = [...mapValues(state.records)]; + const records = mapValueSnapshot(state.records); for (const record of records) { const active = record.activeIntent; const queued = record.queuedIntent; @@ -796,6 +1108,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { readonly adUnitCode: string | undefined; readonly aliases: readonly string[]; readonly id: string; + readonly placementKeys: readonly string[]; readonly source: SlotSource; }> = []; const ids = new Set(); @@ -820,8 +1133,18 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (setHasValue(ids, id) || mapValue(registeredSlots, id)) { return Object.freeze({ ok: false, reason: 'duplicate_slot' }); } + const registrationPlacementKeys = placementKeysFor(id, adUnitCode, aliases); + if (hasPlacementQuarantine(registrationPlacementKeys)) { + return Object.freeze({ ok: false, reason: 'slot_quarantined' }); + } addSetValue(ids, id); - prepared[prepared.length] = { adUnitCode, aliases, id, source }; + prepared[prepared.length] = { + adUnitCode, + aliases, + id, + placementKeys: registrationPlacementKeys, + source, + }; } let state: NavigationState | undefined; @@ -856,9 +1179,16 @@ export function createSlotService(options: SlotServiceOptions): SlotService { state, view, }; - setMapValue(registeredSlots, registration.id, record); + inserted[inserted.length] = record; try { + setMapValue(registeredSlots, registration.id, record); + if (mapValue(registeredSlots, registration.id) !== record) { + throw new Error('slot publication failed'); + } setMapValue(state.records, registration.id, record); + if (mapValue(state.records, registration.id) !== record) { + throw new Error('slot publication failed'); + } if (registration.adUnitCode !== undefined) { setIndexValue(adUnitCodes, registration.adUnitCode, record); } @@ -872,7 +1202,6 @@ export function createSlotService(options: SlotServiceOptions): SlotService { for (const alias of registration.aliases) deleteIndexValue(domAliases, alias, record); throw error; } - inserted[inserted.length] = record; } if (!owner.isCurrent() || state.disposed) throw new Error('stale owner'); state.nextOrdinal += prepared.length; @@ -922,7 +1251,24 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (ownership !== 'publisher' && ownership !== 'trusted_server') { return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); } + if (ownership === 'trusted_server' && definition === undefined) { + return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); + } const slotObject = slot as object; + let bindingPlacementKeys: readonly string[]; + try { + bindingPlacementKeys = placementKeysFor( + record.view.registeredSlotId, + record.view.adUnitCode, + record.view.domAliases, + definition + ); + } catch { + return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); + } + if (hasPlacementQuarantine(bindingPlacementKeys)) { + return Object.freeze({ ok: false, reason: 'slot_quarantined' }); + } const existing = weakMapValue(physicalByObject, slotObject); if (existing) { if (existing.state === 'quarantined') { @@ -934,11 +1280,33 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (existing.record && existing.record !== record) { return Object.freeze({ ok: false, reason: 'gpt_object_collision' }); } - existing.record = record; - existing.ownership = ownership; - existing.definition = definition; - record.physical = existing; - return Object.freeze({ ok: true }); + if (record.physical && record.physical !== existing) { + return Object.freeze({ ok: false, reason: 'gpt_object_collision' }); + } + const wasStrong = setHasValue(physicalSlots, existing); + const previousRecord = existing.record; + const previousOwnership = existing.ownership; + const previousDefinition = existing.definition; + const previousPlacementKeys = existing.placementKeys; + try { + if (!wasStrong) addSetValue(physicalSlots, existing); + if (!setHasValue(physicalSlots, existing)) throw new Error('physical publication failed'); + if (!state.owner.isCurrent() || state.disposed) throw new Error('stale owner'); + existing.record = record; + existing.ownership = ownership; + existing.definition = definition; + existing.placementKeys = bindingPlacementKeys; + record.physical = existing; + return Object.freeze({ ok: true }); + } catch { + if (record.physical === existing) record.physical = undefined; + existing.record = previousRecord; + existing.ownership = previousOwnership; + existing.definition = previousDefinition; + existing.placementKeys = previousPlacementKeys; + if (!wasStrong) deleteSetValue(physicalSlots, existing); + return Object.freeze({ ok: false, reason: 'stale_owner' }); + } } if (record.physical && record.physical.slot !== slotObject) { return Object.freeze({ ok: false, reason: 'gpt_object_collision' }); @@ -946,9 +1314,11 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const physical: PhysicalSlot = { activeCycle: undefined, definition, + destroyAttempted: false, lastResponseIdentifier: undefined, ownership, - publisherIntent: false, + placementKeys: bindingPlacementKeys, + publisherIntentCount: 0, quarantineReason: undefined, record, slot: slotObject, @@ -980,8 +1350,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { record ?? ({ activeIntent: undefined, queuedIntent: undefined } as InternalSlotRecord); const intent: RequestIntent = { completionTimer: undefined, + completionDeadlineAt: undefined, input, invocation: undefined, + requestDeadlineAt: undefined, requestStartedAt: undefined, requestTimer: undefined, resolve, @@ -1011,15 +1383,41 @@ export function createSlotService(options: SlotServiceOptions): SlotService { settle(intent, failed('gpt_request_failed')); return handle; } - if (physical.state === 'quarantined') { - settle(intent, failed('slot_quarantined')); + if (physical.publisherIntentCount > 0 || physical.activeCycle) { + settle( + intent, + failed( + physical.activeCycle?.kind === 'trusted_server' && physical.state === 'quarantined' + ? 'slot_quarantined' + : 'cycle_unattributable' + ) + ); return handle; } - if (physical.publisherIntent) { - settle(intent, failed('cycle_unattributable')); + if (physical.state === 'quarantined') { + settle(intent, failed('slot_quarantined')); return handle; } if (record.activeIntent) { + if (record.activeIntent.input.requestClass !== input.requestClass) { + const active = record.activeIntent; + const activePhysical = record.physical; + const hadStarted = active.requestStartedAt !== undefined; + if (activePhysical?.activeCycle?.intent === active) { + activePhysical.activeCycle.intent = undefined; + activePhysical.state = 'quarantined'; + activePhysical.quarantineReason = 'completion'; + } + settle(active, failed('cycle_unattributable')); + if (record.queuedIntent) { + settle(record.queuedIntent, failed('cycle_unattributable')); + } + settle(intent, failed('cycle_unattributable')); + if (hadStarted && activePhysical && !activePhysical.activeCycle) { + recoverRequestTimeout(record, activePhysical); + } + return handle; + } intent.state = 'queued'; const queued = record.queuedIntent; if (queued) { @@ -1067,32 +1465,64 @@ export function createSlotService(options: SlotServiceOptions): SlotService { slots[slots.length] = physical.slot; } if (slots.length === intents.length) { - let subscriptions: BindingSubscriptionAdmission | undefined; + let subscriptionOperation: GoogletagOperation; + let provisionalSubscriptions: BindingSubscriptionAdmission | undefined; try { - const operation = options.googletag.run((gpt) => { - subscriptions = ensureBindingSubscriptions(gpt); - for (const intent of intents) { - if (!intent.terminal) armRequestDeadline(intent); - } - gpt.refresh(slots, Object.freeze({ changeCorrelator: false })); + subscriptionOperation = options.googletag.run((gpt) => { + provisionalSubscriptions = ensureBindingSubscriptions(gpt); + return provisionalSubscriptions; }); - for (const intent of intents) { - intent.invocation = operation; - if (intent.terminal) operation.dispose(); - } - void operation.result.then( - () => undefined, - () => { - if (subscriptions?.installed) subscriptions.ownership.release(); + void subscriptionOperation.result.then( + (subscriptions) => { + retireHistoricalSubscriptions(subscriptions.ownership); + let operation: GoogletagOperation; + try { + operation = options.googletag.run((gpt) => { + if (gpt.bindingToken() !== subscriptions.ownership.token) { + throw new Error('GPT binding changed before SRA invocation'); + } + for (const intent of intents) { + if (!intent.terminal) armRequestDeadline(intent); + } + gpt.refresh(slots, Object.freeze({ changeCorrelator: false })); + }); + } catch { + for (const intent of intents) failExternalInvocation(intent.record, intent); + return; + } + const liveIntents: RequestIntent[] = []; for (const intent of intents) { - if (!intent.terminal) settle(intent, failed('gpt_request_failed')); + if (!intent.terminal) liveIntents[liveIntents.length] = intent; + } + let remaining = liveIntents.length; + for (const intent of liveIntents) { + let released = false; + intent.invocation = { + dispose: (): void => { + if (released) return; + released = true; + remaining -= 1; + if (remaining === 0) operation.dispose(); + }, + }; } + if (remaining === 0) operation.dispose(); + void operation.result.then( + () => undefined, + () => { + for (const intent of intents) failExternalInvocation(intent.record, intent); + } + ); + }, + () => { + if (provisionalSubscriptions?.installed) provisionalSubscriptions.ownership.release(); + for (const intent of intents) failExternalInvocation(intent.record, intent); } ); } catch { - if (subscriptions?.installed) subscriptions.ownership.release(); + if (provisionalSubscriptions?.installed) provisionalSubscriptions.ownership.release(); for (const intent of intents) { - if (!intent.terminal) settle(intent, failed('gpt_request_failed')); + failExternalInvocation(intent.record, intent); } } } @@ -1111,6 +1541,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { activation = operation; void operation.result.then( () => { + if (subscriptions) retireHistoricalSubscriptions(subscriptions.ownership); if (activation === operation) activation = undefined; }, () => { @@ -1124,8 +1555,12 @@ export function createSlotService(options: SlotServiceOptions): SlotService { dispose: (): void => { if (disposed) return; disposed = true; - for (const state of [...mapValues(navigationStates)]) disposeNavigationState(state); - const subscriptions = [...setValues(bindingSubscriptions)]; + const states = mapValueSnapshot(navigationStates); + for (let index = 0; index < states.length; index += 1) { + const state = states[index]; + if (state) disposeNavigationState(state); + } + const subscriptions = setValueSnapshot(bindingSubscriptions); for (let index = subscriptions.length - 1; index >= 0; index -= 1) { try { subscriptions[index]?.release(); @@ -1185,20 +1620,57 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return service.prepareProjectionSlots(owner, slots); }, }), + recordPublisherDestruction: (slot: object): boolean => { + const physical = weakMapValue(physicalByObject, slot); + if (!physical) return false; + const record = physical.record; + const cycleIntent = physical.activeCycle?.intent; + if (cycleIntent && !cycleIntent.terminal) settle(cycleIntent, failed('gpt_request_failed')); + if (record?.activeIntent) settle(record.activeIntent, failed('gpt_request_failed')); + if (record?.queuedIntent) settle(record.queuedIntent, failed('gpt_request_failed')); + if (record?.physical === physical) record.physical = undefined; + physical.record = undefined; + physical.activeCycle = undefined; + physical.publisherIntentCount = 0; + physical.state = 'retired'; + releasePhysicalPlacement(physical); + deleteSetValue(physicalSlots, physical); + if (weakMapValue(physicalByObject, slot) === physical) { + deleteWeakMapValue(physicalByObject, slot); + } + return true; + }, recordPublisherIntent: (slot: object): boolean => { const physical = weakMapValue(physicalByObject, slot); - if (!physical || physical.state !== 'live' || physical.activeCycle) return false; + if (!physical || (physical.state !== 'live' && !physical.activeCycle)) return false; + if (physical.publisherIntentCount >= MAX_PENDING_PUBLISHER_INTENTS) { + physical.state = 'quarantined'; + physical.quarantineReason = 'request'; + quarantinePhysicalPlacement(physical); + if (physical.record?.activeIntent) { + settle(physical.record.activeIntent, failed('cycle_unattributable')); + } + if (physical.record?.queuedIntent) { + settle(physical.record.queuedIntent, failed('cycle_unattributable')); + } + return false; + } if (physical.record?.activeIntent) { settle(physical.record.activeIntent, failed('cycle_unattributable')); } if (physical.record?.queuedIntent) { settle(physical.record.queuedIntent, failed('cycle_unattributable')); } - physical.publisherIntent = true; + physical.publisherIntentCount += 1; + if (physical.activeCycle?.kind === 'trusted_server') { + physical.activeCycle = { intent: undefined, kind: 'publisher' }; + physical.state = 'quarantined'; + physical.quarantineReason = 'completion'; + } return true; }, registeredSlotIdsForTest: (): readonly string[] => { - const records = [...mapValues(registeredSlots)]; + const records = mapValueSnapshot(registeredSlots); records.sort((left, right) => left.view.ordinal - right.view.ordinal); return Object.freeze(records.map(({ view }) => view.registeredSlotId)); }, @@ -1212,13 +1684,19 @@ export function createSlotService(options: SlotServiceOptions): SlotService { snapshotForTest: () => { let cycles = 0; let intents = 0; - for (const physical of setValues(physicalSlots)) { - if (physical.activeCycle) cycles += 1; + const physicalSnapshot = setValueSnapshot(physicalSlots); + for (let index = 0; index < physicalSnapshot.length; index += 1) { + if (physicalSnapshot[index]?.activeCycle) cycles += 1; } - for (const state of mapValues(navigationStates)) { - for (const record of mapValues(state.records)) { - if (record.activeIntent) intents += 1; - if (record.queuedIntent) intents += 1; + const stateSnapshot = mapValueSnapshot(navigationStates); + for (let stateIndex = 0; stateIndex < stateSnapshot.length; stateIndex += 1) { + const state = stateSnapshot[stateIndex]; + if (!state) continue; + const recordSnapshot = mapValueSnapshot(state.records); + for (let recordIndex = 0; recordIndex < recordSnapshot.length; recordIndex += 1) { + const record = recordSnapshot[recordIndex]; + if (record?.activeIntent) intents += 1; + if (record?.queuedIntent) intents += 1; } } return Object.freeze({ diff --git a/crates/trusted-server-js/lib/src/services/targeting.ts b/crates/trusted-server-js/lib/src/services/targeting.ts index 241a4f3a7..939bb6b12 100644 --- a/crates/trusted-server-js/lib/src/services/targeting.ts +++ b/crates/trusted-server-js/lib/src/services/targeting.ts @@ -62,6 +62,9 @@ const mapSetIntrinsic = Map.prototype.set; const mapSizeGetter = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.get as ( this: Map ) => number; +const mapIteratorNextIntrinsic = Object.getPrototypeOf(new Map().entries()).next as ( + this: IterableIterator +) => IteratorResult; const weakMapDeleteIntrinsic = WeakMap.prototype.delete; const weakMapGetIntrinsic = WeakMap.prototype.get; const weakMapSetIntrinsic = WeakMap.prototype.set; @@ -108,6 +111,14 @@ function exactInstalledValue(values: readonly string[], installed: string): bool return values.length === 1 && values[0] === installed; } +function exactValues(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + function copyValues(values: readonly string[]): readonly string[] { if (!Array.isArray(values)) { throw new TypeError('GPT targeting values must be strings'); @@ -131,6 +142,9 @@ export function createTargetingService(): TargetingService { const setAddIntrinsic = Set.prototype.add; const setDeleteIntrinsic = Set.prototype.delete; const setValuesIntrinsic = Set.prototype.values; + const setIteratorNextIntrinsic = Object.getPrototypeOf(new Set().values()).next as ( + this: IterableIterator + ) => IteratorResult; let disposed = false; let frameCount = 0; let slotCount = 0; @@ -149,6 +163,14 @@ export function createTargetingService(): TargetingService { Reflect.apply(setDeleteIntrinsic, observationReleases, [release]) as boolean; const observationValues = (): IterableIterator<() => void> => Reflect.apply(setValuesIntrinsic, observationReleases, []) as IterableIterator<() => void>; + const setSnapshot = (iterator: IterableIterator): Value[] => { + const values: Value[] = []; + while (true) { + const step = Reflect.apply(setIteratorNextIntrinsic, iterator, []) as IteratorResult; + if (step.done) return values; + values[values.length] = step.value; + } + }; const removeEmptySlot = (slot: object, slotChains: Map): void => { if (mapSize(slotChains) !== 0) return; @@ -175,15 +197,51 @@ export function createTargetingService(): TargetingService { removeEmptySlot(slot, slotChains); }; - const release = (frame: TargetingFrame): void => { - if (!frame.alive) return; + const removeFrame = ( + frame: TargetingFrame, + slotChains: Map, + chain: TargetingChain, + frameIndex: number + ): void => { + const successor = chain.frames[frameIndex + 1]; + if (successor) successor.predecessor = frame.predecessor; + for (let index = frameIndex; index < chain.frames.length - 1; index += 1) { + const next = chain.frames[index + 1]; + if (next) chain.frames[index] = next; + } + chain.frames.length -= 1; + frame.alive = false; + frameCount -= 1; + deleteLiveFrame(frame); + if (chain.frames.length === 0) deleteMapValue(slotChains, frame.key); + removeEmptySlot(frame.slot, slotChains); + }; + + const expectedPredecessor = (frame: TargetingFrame): readonly string[] | undefined => { + const predecessor = frame.predecessor; + if (predecessor.kind === 'publisher') return predecessor.values; + return predecessor.alive ? Object.freeze([predecessor.installed]) : undefined; + }; + + const restorePredecessor = (frame: TargetingFrame): void => { + const predecessor = frame.predecessor; + if (predecessor.kind === 'publisher') { + if (predecessor.values.length === 0) frame.boundary.clearTargeting(frame.key); + else frame.boundary.setTargeting(frame.key, predecessor.values); + } else if (predecessor.alive) { + frame.boundary.setTargeting(frame.key, predecessor.installed); + } + }; + + const release = (frame: TargetingFrame): boolean => { + if (!frame.alive) return true; const slotChains = weakMapValue(chainsBySlot, frame.slot); const chain = slotChains ? mapValue(slotChains, frame.key) : undefined; if (!slotChains || !chain) { frame.alive = false; frameCount -= 1; deleteLiveFrame(frame); - return; + return true; } let frameIndex = -1; for (let index = 0; index < chain.frames.length; index += 1) { @@ -196,47 +254,69 @@ export function createTargetingService(): TargetingService { frame.alive = false; frameCount -= 1; deleteLiveFrame(frame); - return; + return true; } const wasTop = frameIndex === chain.frames.length - 1; - const successor = chain.frames[frameIndex + 1]; - if (successor) successor.predecessor = frame.predecessor; - for (let index = frameIndex; index < chain.frames.length - 1; index += 1) { - const next = chain.frames[index + 1]; - if (next) chain.frames[index] = next; + if (!wasTop) { + removeFrame(frame, slotChains, chain, frameIndex); + return true; } - chain.frames.length -= 1; - frame.alive = false; - frameCount -= 1; - deleteLiveFrame(frame); - - if (!wasTop) return; - if (chain.frames.length === 0) deleteMapValue(slotChains, frame.key); - removeEmptySlot(frame.slot, slotChains); let actual: readonly string[]; try { actual = copyValues(frame.boundary.getTargeting(frame.key)); } catch { - return; + return false; } if (!exactInstalledValue(actual, frame.installed)) { - if (chain.frames.length > 0) invalidateChain(frame.slot, slotChains, frame.key, chain); - return; + invalidateChain(frame.slot, slotChains, frame.key, chain); + return true; } - const predecessor = frame.predecessor; + const expected = expectedPredecessor(frame); + if (!expected) return false; try { - if (predecessor.kind === 'publisher') { - if (predecessor.values.length === 0) frame.boundary.clearTargeting(frame.key); - else frame.boundary.setTargeting(frame.key, predecessor.values); - } else if (predecessor.alive) { - frame.boundary.setTargeting(frame.key, predecessor.installed); - } + restorePredecessor(frame); + } catch { + // The post-failure read below distinguishes mutate-then-throw from no mutation. + } + let restored: readonly string[]; + try { + restored = copyValues(frame.boundary.getTargeting(frame.key)); + } catch { + return false; + } + let exact = restored.length === expected.length; + for (let index = 0; exact && index < restored.length; index += 1) { + exact = restored[index] === expected[index]; + } + if (!exact) { + return false; + } + removeFrame(frame, slotChains, chain, frameIndex); + return true; + }; + + const rollbackFailedInstallation = (frame: TargetingFrame): boolean => { + const slotChains = weakMapValue(chainsBySlot, frame.slot); + const chain = slotChains ? mapValue(slotChains, frame.key) : undefined; + if (!slotChains || !chain) return true; + const frameIndex = chain.frames.length - 1; + if (chain.frames[frameIndex] !== frame) return false; + let actual: readonly string[]; + try { + actual = copyValues(frame.boundary.getTargeting(frame.key)); } catch { - // Restoration is compare-checked and best-effort; ownership is still released exactly once. + return false; } + const predecessor = expectedPredecessor(frame); + if (predecessor && exactValues(actual, predecessor)) { + removeFrame(frame, slotChains, chain, frameIndex); + return true; + } + if (exactInstalledValue(actual, frame.installed)) return release(frame); + return false; }; const invalidatePublisherMutation = (slot: object, key?: string): void => { @@ -252,7 +332,13 @@ export function createTargetingService(): TargetingService { const iterator = Reflect.apply(mapEntriesIntrinsic, slotChains, []) as IterableIterator< [string, TargetingChain] >; - for (const entry of iterator) entries[entries.length] = entry; + while (true) { + const step = Reflect.apply(mapIteratorNextIntrinsic, iterator, []) as IteratorResult< + [string, TargetingChain] + >; + if (step.done) break; + entries[entries.length] = step.value; + } for (const [entryKey, chain] of entries) invalidateChain(slot, slotChains, entryKey, chain); }; @@ -306,37 +392,46 @@ export function createTargetingService(): TargetingService { try { if (wasNewSlot) { setWeakMapValue(chainsBySlot, slot, slotChains); + if (weakMapValue(chainsBySlot, slot) !== slotChains) throw new Error('journal publication'); publishedWeakMap = true; slotCount += 1; } if (wasNewChain) { setMapValue(slotChains, key, chain); + if (mapValue(slotChains, key) !== chain) throw new Error('journal publication'); publishedChain = true; } chain.frames[chain.frames.length] = frame; publishedFrame = true; addLiveFrame(frame); frameCount += 1; - targeting.setTargeting(key, value); } catch (error) { if (publishedFrame) chain.frames.length -= 1; frame.alive = false; if (deleteLiveFrame(frame) && frameCount > 0) frameCount -= 1; - if (publishedChain && chain.frames.length === 0) deleteMapValue(slotChains, key); - if (publishedWeakMap && mapSize(slotChains) === 0) { + if ((publishedChain || mapValue(slotChains, key) === chain) && chain.frames.length === 0) { + deleteMapValue(slotChains, key); + } + if (weakMapValue(chainsBySlot, slot) === slotChains && mapSize(slotChains) === 0) { deleteWeakMapValue(chainsBySlot, slot); - if (slotCount > 0) slotCount -= 1; + if (publishedWeakMap && slotCount > 0) slotCount -= 1; } throw error; } + try { + targeting.setTargeting(key, value); + } catch (error) { + rollbackFailedInstallation(frame); + throw error; + } + let released = false; return Object.freeze({ ownerId, release: (): void => { if (released) return; - released = true; - release(frame); + released = release(frame); }, }); }; @@ -345,12 +440,12 @@ export function createTargetingService(): TargetingService { dispose: (): void => { if (disposed) return; disposed = true; - const frames = [...liveFrameValues()]; + const frames = setSnapshot(liveFrameValues()); for (let index = frames.length - 1; index >= 0; index -= 1) { const frame = frames[index]; if (frame) release(frame); } - const observations = [...observationValues()]; + const observations = setSnapshot(observationValues()); for (let index = observations.length - 1; index >= 0; index -= 1) { try { observations[index]?.(); @@ -360,7 +455,7 @@ export function createTargetingService(): TargetingService { } }, disposeOwner: (ownerId: string): void => { - const frames = [...liveFrameValues()]; + const frames = setSnapshot(liveFrameValues()); for (let index = frames.length - 1; index >= 0; index -= 1) { const frame = frames[index]; if (frame?.ownerId === ownerId) release(frame); @@ -368,6 +463,7 @@ export function createTargetingService(): TargetingService { }, invalidatePublisherMutation, observePublisherMutations: (slot: object, adapter: GoogletagAdapter) => { + let ownedRelease = (): void => undefined; const operation = adapter.run((gpt) => { if (disposed) return; let release = gpt.observeTargeting( @@ -379,7 +475,7 @@ export function createTargetingService(): TargetingService { }) ); let active = true; - const ownedRelease = (): void => { + ownedRelease = (): void => { if (!active) return; active = false; deleteObservationRelease(ownedRelease); @@ -395,8 +491,19 @@ export function createTargetingService(): TargetingService { } if (disposed) ownedRelease(); }); - void operation.result.catch(() => undefined); - return operation; + void operation.result.catch(() => { + ownedRelease(); + }); + return Object.freeze({ + get status() { + return operation.status; + }, + result: operation.result, + dispose: (): void => { + ownedRelease(); + operation.dispose(); + }, + }); }, own, snapshotForTest: () => Object.freeze({ frames: frameCount, slots: slotCount }), diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index a445a13d1..8af460976 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createBrowserGoogletagAdapter, + GoogletagReplacementError, type GoogletagAdapter, type GoogletagFacade, + type GoogletagReplacementCommitAdmission, type GoogletagReplacementDefinition, } from '../../src/adapters/googletag'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; @@ -39,6 +41,8 @@ function createGptHarness( options: { initialLoadDisabled?: boolean; missingRefresh?: boolean; + orphanOnReplace?: object; + returnOldOnReplace?: boolean; synchronousRun?: boolean; } = {} ) { @@ -83,22 +87,45 @@ function createGptHarness( transactionalReplace: ( oldSlot: object, definition: GoogletagReplacementDefinition | undefined, - isCurrent: () => boolean + isCurrent: () => boolean, + prepareCommit: (replacement: object) => GoogletagReplacementCommitAdmission ) => { - if (!destroySlots([oldSlot])) return undefined; - if (!definition || !isCurrent()) return undefined; - const replacement = defineSlot(definition.adUnitPath, definition.sizes, definition.elementId); - if (!replacement) return undefined; + 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 undefined; + return Object.freeze({ status: 'destroyed' as const }); } addService(replacement); + if (options.orphanOnReplace) { + throw new GoogletagReplacementError(options.orphanOnReplace, true); + } if (!isCurrent()) { destroySlots([replacement]); - return undefined; + return Object.freeze({ status: 'destroyed' as const }); } - return replacement; + 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({ @@ -112,7 +139,7 @@ function createGptHarness( }); operationDisposals.push(dispose); let result: Promise; - if (options.synchronousRun) { + if (options.synchronousRun !== false) { try { result = Promise.resolve(command(facade)); } catch (error) { @@ -399,8 +426,9 @@ describe('adapter-owned GPT replacement transaction', () => { elementId: 'slot-div', sizes: Object.freeze([[300, 250]]), }); + const commitReplacement = () => Object.freeze({ commit: () => true, rollback: vi.fn() }); - it.each(['throw', 'false', 'define'] as const)( + it.each(['throw', 'false'] as const)( 'never publishes a second physical slot after %s failure', async (failure) => { const harness = createReplacementHarness(); @@ -410,15 +438,13 @@ describe('adapter-owned GPT replacement transaction', () => { }); } else if (failure === 'false') { harness.destroySlots.mockReturnValue(false); - } else { - harness.defineSlot.mockReturnValue(undefined); } const operation = harness.adapter.run((gpt) => - gpt.transactionalReplace({}, definition, () => true) + gpt.transactionalReplace({}, definition, () => true, commitReplacement) ); await expect(operation.result).rejects.toBeDefined(); - expect(harness.defineSlot).toHaveBeenCalledTimes(failure === 'define' ? 1 : 0); + expect(harness.defineSlot).not.toHaveBeenCalled(); expect(harness.replacement.addService).not.toHaveBeenCalled(); } ); @@ -433,13 +459,18 @@ describe('adapter-owned GPT replacement transaction', () => { const harness = createReplacementHarness(); let checks = 0; const operation = harness.adapter.run((gpt) => - gpt.transactionalReplace({}, definition, () => { - checks += 1; - return checks < staleAt; - }) + gpt.transactionalReplace( + {}, + definition, + () => { + checks += 1; + return checks < staleAt; + }, + commitReplacement + ) ); - await expect(operation.result).resolves.toBeUndefined(); + 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); @@ -451,15 +482,132 @@ describe('adapter-owned GPT replacement transaction', () => { harness.destroySlots.mockReturnValueOnce(true).mockReturnValueOnce(false); let checks = 0; const operation = harness.adapter.run((gpt) => - gpt.transactionalReplace({}, definition, () => { - checks += 1; - return checks < 2; - }) + gpt.transactionalReplace( + {}, + definition, + () => { + checks += 1; + return checks < 2; + }, + commitReplacement + ) ); await expect(operation.result).rejects.toBeDefined(); expect(harness.destroySlots).toHaveBeenCalledTimes(2); }); + + 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() { @@ -518,6 +666,7 @@ describe('binding-aware GPT listener activation', () => { 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); @@ -765,10 +914,9 @@ describe('physical GPT cycles', () => { reason: 'cycle_unattributable', status: 'failed', }); - active.dispose(); await expect(active.result).resolves.toEqual({ - reason: 'superseded', - status: 'cancelled', + reason: 'cycle_unattributable', + status: 'failed', }); }); @@ -826,7 +974,9 @@ describe('physical GPT cycles', () => { registeredSlotId: 'slot', }); await expect(request.result).resolves.toMatchObject({ status: 'rendered' }); - expect(harness.operationDisposals[0]).toHaveBeenCalledOnce(); + expect( + harness.operationDisposals[harness.operationDisposals.length - 1] + ).toHaveBeenCalledOnce(); }); it('safe-retires an invoked pre-cycle cancellation instead of clearing its only safety timer', async () => { @@ -1148,7 +1298,7 @@ describe('physical GPT cycles', () => { }); }); - it.each(['throw', 'false', 'define'] as const)( + it.each(['throw', 'false'] as const)( 'keeps one retired object and quarantines failed request-timeout recovery: %s', async (failure) => { vi.useFakeTimers(); @@ -1157,10 +1307,8 @@ describe('physical GPT cycles', () => { harness.destroySlots.mockImplementation(() => { throw new Error('destroy failed'); }); - } else if (failure === 'false') { - harness.destroySlots.mockReturnValue(false); } else { - harness.defineSlot.mockReturnValue(undefined); + harness.destroySlots.mockReturnValue(false); } const service = createSlotService({ googletag: harness.adapter }); const navigation = createNavigation(); @@ -1188,7 +1336,7 @@ describe('physical GPT cycles', () => { reason: 'gpt_request_failed', status: 'failed', }); - expect(harness.defineSlot).toHaveBeenCalledTimes(failure === 'define' ? 1 : 0); + expect(harness.defineSlot).not.toHaveBeenCalled(); expect(service.snapshotForTest().physicalSlots).toBe(1); } ); @@ -1301,6 +1449,32 @@ describe('physical GPT cycles', () => { ); }); + 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) => { @@ -1373,3 +1547,810 @@ describe('physical GPT cycles', () => { 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('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('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 a deferred SRA batch after its navigation is disposed', async () => { + const harness = createGptHarness({ synchronousRun: false }); + 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 service = createSlotService({ + googletag: createGptHarness().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' }); + }); + + 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('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(), + 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('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 index 09f3a8d07..dbca27d98 100644 --- a/crates/trusted-server-js/lib/test/services/targeting.test.ts +++ b/crates/trusted-server-js/lib/test/services/targeting.test.ts @@ -134,6 +134,8 @@ describe('owner-aware targeting journal', () => { 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 }); }); @@ -313,4 +315,251 @@ describe('adapter-owned targeting interception', () => { 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('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/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index 4249814a3..7d81582cb 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -702,9 +702,9 @@ deadline measured from enqueue and fails `external_ready_timeout`; public `requestAds.timeoutMs` never shortens or extends it. After a request-capable `display()` or `refresh()` is invoked, `slotRequested` must arrive within three seconds or the attempt fails -`gpt_request_timeout`. Once `slotRequested` arrives, its matching -`slotRenderEnded` must arrive within ten seconds or the attempt fails -`gpt_completion_timeout`. A timeout tombstones the reservation, closes owned ports, +`gpt_request_timeout`. Its matching `slotRenderEnded` must arrive within ten seconds +of that same request invocation or the attempt fails `gpt_completion_timeout`. +A timeout tombstones the reservation, closes owned ports, and settles the attempt, but does not pretend the physical GPT cycle completed. At `gpt_request_timeout`, no attributable physical cycle exists. The adapter From 0807e61b66c481fa47ebfe629eeec72e606a9482 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:52:17 -0700 Subject: [PATCH 031/194] Fix GPT cycle queue admission races --- .../lib/src/services/slots.ts | 70 +++++++++++++++++-- .../lib/test/services/slots.test.ts | 53 +++++++++++++- 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 234f74f4a..2d434b686 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -1383,22 +1383,37 @@ export function createSlotService(options: SlotServiceOptions): SlotService { settle(intent, failed('gpt_request_failed')); return handle; } - if (physical.publisherIntentCount > 0 || physical.activeCycle) { + if (physical.publisherIntentCount > 0) { + settle(intent, failed('cycle_unattributable')); + return handle; + } + if (physical.state === 'quarantined') { settle( intent, failed( physical.activeCycle?.kind === 'trusted_server' && physical.state === 'quarantined' ? 'slot_quarantined' - : 'cycle_unattributable' + : physical.activeCycle + ? 'cycle_unattributable' + : 'slot_quarantined' ) ); return handle; } - if (physical.state === 'quarantined') { - settle(intent, failed('slot_quarantined')); - return handle; - } if (record.activeIntent) { + if ( + physical.activeCycle && + (physical.activeCycle.kind !== 'trusted_server' || + physical.activeCycle.intent !== record.activeIntent) + ) { + const active = record.activeIntent; + settle(active, failed('cycle_unattributable')); + if (record.queuedIntent) { + settle(record.queuedIntent, failed('cycle_unattributable')); + } + settle(intent, failed('cycle_unattributable')); + return handle; + } if (record.activeIntent.input.requestClass !== input.requestClass) { const active = record.activeIntent; const activePhysical = record.physical; @@ -1431,6 +1446,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { record.queuedIntent = intent; return handle; } + if (physical.activeCycle) { + settle(intent, failed('cycle_unattributable')); + return handle; + } record.activeIntent = intent; intent.state = 'active'; if (!deferInvocations) invokeIntent(record, intent); @@ -1465,6 +1484,38 @@ export function createSlotService(options: SlotServiceOptions): SlotService { slots[slots.length] = physical.slot; } if (slots.length === intents.length) { + const allIntentsAdmitted = (): boolean => { + for (let index = 0; index < intents.length; index += 1) { + const intent = intents[index]; + const expectedSlot = slots[index]; + if (!intent || intent.terminal || intent.record.activeIntent !== intent) return false; + const state = intent.record.state; + if (state.disposed || !state.owner.isCurrent()) return false; + const physical = intent.record.physical; + if ( + !physical || + physical.state !== 'live' || + physical.activeCycle || + physical.slot !== expectedSlot + ) { + return false; + } + } + return true; + }; + const failInvalidAdmission = (): void => { + for (let index = 0; index < intents.length; index += 1) { + const intent = intents[index]; + if (!intent || intent.terminal) continue; + const state = intent.record.state; + settle( + intent, + state.disposed || !state.owner.isCurrent() + ? cancelled('navigation_disposed') + : failed('gpt_request_failed') + ); + } + }; let subscriptionOperation: GoogletagOperation; let provisionalSubscriptions: BindingSubscriptionAdmission | undefined; try { @@ -1475,12 +1526,19 @@ export function createSlotService(options: SlotServiceOptions): SlotService { void subscriptionOperation.result.then( (subscriptions) => { retireHistoricalSubscriptions(subscriptions.ownership); + if (!allIntentsAdmitted()) { + failInvalidAdmission(); + return; + } let operation: GoogletagOperation; try { operation = options.googletag.run((gpt) => { if (gpt.bindingToken() !== subscriptions.ownership.token) { throw new Error('GPT binding changed before SRA invocation'); } + if (!allIntentsAdmitted()) { + throw new Error('SRA admission changed before invocation'); + } for (const intent of intents) { if (!intent.terminal) armRequestDeadline(intent); } diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 8af460976..5a4dbea47 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -920,6 +920,55 @@ describe('physical GPT cycles', () => { }); }); + 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('fails active and queued TS work when publisher intent makes ownership ambiguous', async () => { const harness = createGptHarness(); const service = createSlotService({ googletag: harness.adapter }); @@ -1843,8 +1892,8 @@ describe('Task 11 adversarial ownership review', () => { await expect(requests[1]?.result).resolves.toMatchObject({ status: 'empty' }); }); - it('does not invoke a deferred SRA batch after its navigation is disposed', async () => { - const harness = createGptHarness({ synchronousRun: false }); + 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'); From cbae1a2e7334ce637b4afafc8ff018aa3c21bcb1 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:08:15 -0700 Subject: [PATCH 032/194] Harden GPT admission and recovery invariants --- .../lib/src/adapters/googletag.ts | 42 +- .../lib/src/services/slots.ts | 199 ++++++++- .../lib/src/services/targeting.ts | 10 + .../lib/test/services/slots.test.ts | 411 +++++++++++++++++- .../lib/test/services/targeting.test.ts | 34 ++ 5 files changed, 669 insertions(+), 27 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index fa5fb7500..d415d2e65 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -50,13 +50,31 @@ export class GoogletagReplacementError extends Error { public readonly cause: unknown; public readonly oldSlotDestroyed: boolean; public readonly orphanedSlot: object | undefined; - - public constructor(orphanedSlot?: object, oldSlotDestroyed = false, cause?: unknown) { + 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; } } @@ -649,13 +667,6 @@ function createFacade( if (!destroy(stale)) throw new GoogletagReplacementError(stale, true); return destroyed; } - 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; - } admission = prepareCommit(replacement as object); if ( !admission || @@ -664,6 +675,13 @@ function createFacade( ) { 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()) { @@ -695,8 +713,12 @@ function createFacade( // 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); - throw error; + if (error instanceof GoogletagReplacementError) throw error; + throw new GoogletagReplacementError(undefined, true, error); } }, }); diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 2d434b686..a5fdb5fa7 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -6,7 +6,10 @@ import type { GoogletagReplacementDefinition, GoogletagReplacementResult, } from '../adapters/googletag'; -import { GoogletagReplacementError } from '../adapters/googletag'; +import { + GoogletagReplacementCandidateCollisionError, + GoogletagReplacementError, +} from '../adapters/googletag'; import type { NavigationSession } from '../kernel/sessions'; import type { PreparedProjectionSlots, ProjectionSlotRegistry } from './projections'; @@ -92,6 +95,10 @@ export interface SlotRequestInput { readonly requestClass: string; } +/** Refresh-only input accepted by one shared GPT SRA operation. */ +export type SlotBatchRequestInput = Omit & + Readonly<{ operation: 'refresh' }>; + export interface SlotRequestHandle { readonly status: 'active' | 'queued' | 'terminal'; readonly result: Promise; @@ -130,7 +137,7 @@ export interface SlotService { registrations: readonly SlotRegistration[] ) => SlotRegistrationResult; readonly request: (input: SlotRequestInput) => SlotRequestHandle; - readonly requestBatch: (inputs: readonly SlotRequestInput[]) => readonly SlotRequestHandle[]; + readonly requestBatch: (inputs: readonly SlotBatchRequestInput[]) => readonly SlotRequestHandle[]; readonly resolveAdUnitCode: (adUnitCode: string) => SlotRecord | undefined; readonly resolveDomAlias: (alias: string) => SlotRecord | undefined; readonly resolveRegisteredSlot: (registeredSlotId: string) => SlotRecord | undefined; @@ -173,6 +180,7 @@ interface PhysicalSlot { publisherIntentCount: number; quarantineReason: 'completion' | 'navigation' | 'request' | undefined; record: InternalSlotRecord | undefined; + saturationOwner: boolean; readonly slot: object; state: PhysicalSlotState; destroyAttempted: boolean; @@ -392,6 +400,69 @@ function ownData(event: unknown, key: PropertyKey): unknown { } } +function copyReplacementSizes(sizes: unknown): unknown | undefined { + if (!Array.isArray(sizes)) return undefined; + const length = sizes.length; + const copyPair = (value: unknown): readonly [number, number] | undefined => { + if (!Array.isArray(value) || value.length !== 2) return undefined; + const width = value[0] as unknown; + const height = value[1] as unknown; + if ( + typeof width !== 'number' || + !Number.isInteger(width) || + width < 1 || + width > 4_096 || + typeof height !== 'number' || + !Number.isInteger(height) || + height < 1 || + height > 4_096 + ) { + return undefined; + } + return Object.freeze([width, height]); + }; + const single = copyPair(sizes); + if (single) return single; + if (length === 0 || length > MAX_ACTIVE_SLOT_RECORDS) return undefined; + const copied: Array = []; + for (let index = 0; index < length; index += 1) { + const pair = copyPair(sizes[index] as unknown); + if (!pair) return undefined; + copied[copied.length] = pair; + } + return Object.freeze(copied); +} + +function snapshotReplacementDefinition(input: unknown): GoogletagReplacementDefinition | undefined { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; + let adUnitPath: unknown; + let elementId: unknown; + let sizes: unknown; + try { + const external = input as { + readonly adUnitPath?: unknown; + readonly elementId?: unknown; + readonly sizes?: unknown; + }; + adUnitPath = external.adUnitPath; + elementId = external.elementId; + sizes = external.sizes; + } catch { + return undefined; + } + const copiedSizes = copyReplacementSizes(sizes); + if ( + typeof adUnitPath !== 'string' || + !validSlotIdentity(adUnitPath) || + typeof elementId !== 'string' || + !validSlotIdentity(elementId) || + copiedSizes === undefined + ) { + return undefined; + } + return Object.freeze({ adUnitPath, elementId, sizes: copiedSizes }); +} + const failed = (reason: SlotRequestFailure): SlotRequestOutcome => Object.freeze({ status: 'failed' as const, reason }); const cancelled = (reason: 'navigation_disposed' | 'superseded'): SlotRequestOutcome => @@ -428,6 +499,8 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const quarantinedKeysByPhysical = new WeakMap(); const now = options.now ?? (() => performance.now()); let placementQuarantineSaturated = false; + let placementQuarantinePoisoned = false; + let saturationOwnerCount = 0; let disposed = false; let deferInvocations = false; let activation: GoogletagOperation | undefined; @@ -435,7 +508,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const bindingSubscriptions = new Set(); const hasPlacementQuarantine = (keys: readonly string[]): boolean => { - if (placementQuarantineSaturated) return true; + if (placementQuarantineSaturated || placementQuarantinePoisoned) return true; for (let index = 0; index < keys.length; index += 1) { const key = keys[index]; if (key !== undefined && mapValue(placementQuarantine, key) !== undefined) return true; @@ -443,6 +516,13 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return false; }; + const markSaturationOwner = (physical: PhysicalSlot): void => { + if (physical.saturationOwner) return; + physical.saturationOwner = true; + saturationOwnerCount += 1; + placementQuarantineSaturated = true; + }; + const quarantinePhysicalPlacement = (physical: PhysicalSlot): void => { if (weakMapValue(quarantinedKeysByPhysical, physical.slot)) return; let additionalKeys = 0; @@ -452,7 +532,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { additionalKeys += 1; } if (mapSize(placementQuarantine) + additionalKeys > MAX_PLACEMENT_QUARANTINE_KEYS) { - placementQuarantineSaturated = true; + markSaturationOwner(physical); return; } try { @@ -472,11 +552,16 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } } } catch { - placementQuarantineSaturated = true; + markSaturationOwner(physical); } }; const releasePhysicalPlacement = (physical: PhysicalSlot): void => { + if (physical.saturationOwner) { + physical.saturationOwner = false; + saturationOwnerCount -= 1; + if (saturationOwnerCount === 0) placementQuarantineSaturated = false; + } const keys = weakMapValue(quarantinedKeysByPhysical, physical.slot); if (!keys) return; deleteWeakMapValue(quarantinedKeysByPhysical, physical.slot); @@ -538,7 +623,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { throw new Error('gpt_request_failed'); } const existing = weakMapValue(physicalByObject, replacement); - if (existing) throw new Error('gpt_request_failed'); + if (existing) throw new GoogletagReplacementCandidateCollisionError(replacement); const physical: PhysicalSlot = { activeCycle: undefined, definition: oldPhysical.definition, @@ -549,6 +634,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { publisherIntentCount: 0, quarantineReason: undefined, record, + saturationOwner: false, slot: replacement, state: 'live', }; @@ -652,7 +738,11 @@ export function createSlotService(options: SlotServiceOptions): SlotService { physical.state = 'quarantined'; const replacementError = error instanceof GoogletagReplacementError ? error : undefined; const reusedOldIdentity = replacementError?.orphanedSlot === physical.slot; - if (replacementError?.oldSlotDestroyed && !reusedOldIdentity) { + if ( + replacementError?.oldSlotDestroyed && + !replacementError.preserveOldQuarantine && + !reusedOldIdentity + ) { detachDestroyedOld(); } if (replacementError?.orphanedSlot && replacementError.orphanedSlot !== physical.slot) { @@ -666,6 +756,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { publisherIntentCount: 0, quarantineReason: 'request', record: undefined, + saturationOwner: false, slot: replacementError.orphanedSlot, state: 'quarantined', }; @@ -676,7 +767,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } quarantinePhysicalPlacement(orphan); } catch { - placementQuarantineSaturated = true; + placementQuarantinePoisoned = true; } } failQueued(record, 'gpt_request_failed'); @@ -697,6 +788,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } settle(intent, cancelled('superseded')); if (wasInvoked && physical) recoverRequestTimeout(intent.record, physical); + else advanceQueued(intent.record); }; const onRequestTimeout = (intent: RequestIntent): void => { @@ -970,7 +1062,6 @@ export function createSlotService(options: SlotServiceOptions): SlotService { now() > (intent.completionDeadlineAt ?? Number.NEGATIVE_INFINITY) ) { onCompletionTimeout(intent); - return; } const isEmptyValue = ownData(event, 'isEmpty'); if (isEmptyValue !== true && isEmptyValue !== false) return; @@ -1234,11 +1325,11 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (!record) return Object.freeze({ ok: false, reason: 'slot_unresolved' }); let slot: unknown; let ownership: unknown; - let definition: GoogletagReplacementDefinition | undefined; + let externalDefinition: unknown; try { slot = binding.slot; ownership = binding.ownership; - definition = binding.definition; + externalDefinition = binding.definition; } catch { return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); } @@ -1251,7 +1342,14 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (ownership !== 'publisher' && ownership !== 'trusted_server') { return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); } - if (ownership === 'trusted_server' && definition === undefined) { + const definition = + externalDefinition === undefined + ? undefined + : snapshotReplacementDefinition(externalDefinition); + if ( + (externalDefinition !== undefined && definition === undefined) || + (ownership === 'trusted_server' && definition === undefined) + ) { return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); } const slotObject = slot as object; @@ -1321,6 +1419,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { publisherIntentCount: 0, quarantineReason: undefined, record, + saturationOwner: false, slot: slotObject, state: 'live', }; @@ -1456,17 +1555,85 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return handle; }; - const requestBatch = (inputs: readonly SlotRequestInput[]): readonly SlotRequestHandle[] => { - if (!Array.isArray(inputs)) return Object.freeze([]); + const requestBatch = (inputs: readonly SlotBatchRequestInput[]): readonly SlotRequestHandle[] => { + if (!Array.isArray(inputs) || inputs.length === 0 || inputs.length > MAX_ACTIVE_SLOT_RECORDS) { + return Object.freeze([]); + } + const preparedInputs: SlotBatchRequestInput[] = []; + const admittedIntents = new Set(); + const admittedRecords = new Set(); + const admittedPhysicalSlots = new Set(); + let batchGeneration: object | undefined; + try { + for (let index = 0; index < inputs.length; index += 1) { + const input = inputs[index] as unknown; + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return Object.freeze([]); + } + const intentId = ownData(input, 'intentId'); + const navigationGeneration = ownData(input, 'navigationGeneration'); + const operation = ownData(input, 'operation'); + const registeredSlotId = ownData(input, 'registeredSlotId'); + const requestClass = ownData(input, 'requestClass'); + if ( + typeof intentId !== 'string' || + intentId.length === 0 || + typeof navigationGeneration !== 'object' || + navigationGeneration === null || + operation !== 'refresh' || + typeof registeredSlotId !== 'string' || + registeredSlotId.length === 0 || + typeof requestClass !== 'string' || + requestClass.length === 0 || + setHasValue(admittedIntents, intentId) + ) { + return Object.freeze([]); + } + if (batchGeneration === undefined) batchGeneration = navigationGeneration; + else if (batchGeneration !== navigationGeneration) return Object.freeze([]); + const state = mapValue(navigationStates, navigationGeneration); + const record = state ? mapValue(state.records, registeredSlotId) : undefined; + const physical = record?.physical; + if ( + !state || + state.disposed || + !state.owner.isCurrent() || + !record || + record.activeIntent || + record.queuedIntent || + !physical || + physical.record !== record || + physical.state !== 'live' || + physical.activeCycle || + physical.publisherIntentCount > 0 || + setHasValue(admittedRecords, record) || + setHasValue(admittedPhysicalSlots, physical.slot) + ) { + return Object.freeze([]); + } + addSetValue(admittedIntents, intentId); + addSetValue(admittedRecords, record); + addSetValue(admittedPhysicalSlots, physical.slot); + preparedInputs[preparedInputs.length] = Object.freeze({ + intentId, + navigationGeneration, + operation, + registeredSlotId, + requestClass, + }); + } + } catch { + return Object.freeze([]); + } deferInvocations = true; let handles: SlotRequestHandle[]; try { - handles = inputs.map((input) => request(input)); + handles = preparedInputs.map((input) => request(input)); } finally { deferInvocations = false; } const intents: RequestIntent[] = []; - for (const input of inputs) { + for (const input of preparedInputs) { const state = mapValue(navigationStates, input.navigationGeneration); const record = state ? mapValue(state.records, input.registeredSlotId) : undefined; const intent = record?.activeIntent; diff --git a/crates/trusted-server-js/lib/src/services/targeting.ts b/crates/trusted-server-js/lib/src/services/targeting.ts index 939bb6b12..d61d86205 100644 --- a/crates/trusted-server-js/lib/src/services/targeting.ts +++ b/crates/trusted-server-js/lib/src/services/targeting.ts @@ -425,6 +425,16 @@ export function createTargetingService(): TargetingService { rollbackFailedInstallation(frame); throw error; } + let installedExactly = false; + try { + installedExactly = exactInstalledValue(copyValues(targeting.getTargeting(key)), value); + } catch { + // The rollback path below retries observation and retains ownership if still unreadable. + } + if (!installedExactly) { + rollbackFailedInstallation(frame); + throw new Error('GPT targeting postcondition failed'); + } let released = false; return Object.freeze({ diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 5a4dbea47..b99d0fc88 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -497,6 +497,71 @@ describe('adapter-owned GPT replacement transaction', () => { 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('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('rejects a defineSlot candidate that is the retired old object', async () => { const harness = createReplacementHarness(); const oldSlot = { addService: vi.fn() }; @@ -857,6 +922,96 @@ describe('physical GPT cycles', () => { ]); }); + 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('keeps publisher display intent publisher-owned and fails ambiguous overlap', async () => { const harness = createGptHarness(); const service = createSlotService({ googletag: harness.adapter }); @@ -969,6 +1124,50 @@ describe('physical GPT cycles', () => { }); }); + 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 }); @@ -1739,6 +1938,70 @@ describe('Task 11 adversarial ownership review', () => { ).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('counts multiple publisher intents and preserves two publisher cycles', () => { const service = createSlotService({ googletag: createGptHarness().adapter }); const navigation = createNavigation(); @@ -2007,8 +2270,9 @@ describe('Task 11 adversarial ownership review', () => { 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: createGptHarness().adapter, + googletag: harness.adapter, now: () => current, }); const navigation = createNavigation(); @@ -2027,6 +2291,20 @@ describe('Task 11 adversarial ownership review', () => { service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); await expect(request.result).resolves.toMatchObject({ reason: 'gpt_completion_timeout' }); + expect(service.snapshotForTest().cycles).toBe(0); + + 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 }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + await expect(next.result).resolves.toMatchObject({ status: 'rendered' }); }); it('fails active and queued work when publisher intent overlaps the opened TS cycle', async () => { @@ -2205,6 +2483,85 @@ describe('Task 11 adversarial ownership review', () => { 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(); @@ -2349,6 +2706,58 @@ describe('Task 11 adversarial ownership review', () => { }); }); + 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('rolls back a Map publication whose captured set mutates and then throws', async () => { const originalSet = Map.prototype.set; let poison = false; diff --git a/crates/trusted-server-js/lib/test/services/targeting.test.ts b/crates/trusted-server-js/lib/test/services/targeting.test.ts index dbca27d98..cf472f9da 100644 --- a/crates/trusted-server-js/lib/test/services/targeting.test.ts +++ b/crates/trusted-server-js/lib/test/services/targeting.test.ts @@ -408,6 +408,40 @@ describe('adapter-owned targeting interception', () => { }); 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 = {}; From 85403cdfbb9d4a9b2c041cef667a1b314a9380bc Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:21:24 -0700 Subject: [PATCH 033/194] Harden hostile GPT state transitions --- .../lib/src/adapters/googletag.ts | 6 +- .../lib/src/services/slots.ts | 144 +++++--- .../lib/test/services/slots.test.ts | 336 ++++++++++++++++++ 3 files changed, 436 insertions(+), 50 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index d415d2e65..86bedde05 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -637,13 +637,13 @@ function createFacade( throw new GoogletagReplacementError(undefined, true, cause); }; if (!destroy(oldSlot)) throw new GoogletagReplacementError(oldSlot); - if (definition === undefined || !isGenerationCurrent() || !isOperationCurrent()) { - return destroyed; - } 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, diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index a5fdb5fa7..3c658bae8 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -401,36 +401,40 @@ function ownData(event: unknown, key: PropertyKey): unknown { } function copyReplacementSizes(sizes: unknown): unknown | undefined { - if (!Array.isArray(sizes)) return undefined; - const length = sizes.length; - const copyPair = (value: unknown): readonly [number, number] | undefined => { - if (!Array.isArray(value) || value.length !== 2) return undefined; - const width = value[0] as unknown; - const height = value[1] as unknown; - if ( - typeof width !== 'number' || - !Number.isInteger(width) || - width < 1 || - width > 4_096 || - typeof height !== 'number' || - !Number.isInteger(height) || - height < 1 || - height > 4_096 - ) { - return undefined; - } - return Object.freeze([width, height]); - }; - const single = copyPair(sizes); - if (single) return single; - if (length === 0 || length > MAX_ACTIVE_SLOT_RECORDS) return undefined; - const copied: Array = []; - for (let index = 0; index < length; index += 1) { - const pair = copyPair(sizes[index] as unknown); - if (!pair) return undefined; - copied[copied.length] = pair; + try { + if (!Array.isArray(sizes)) return undefined; + const length = sizes.length; + const copyPair = (value: unknown): readonly [number, number] | undefined => { + if (!Array.isArray(value) || value.length !== 2) return undefined; + const width = value[0] as unknown; + const height = value[1] as unknown; + if ( + typeof width !== 'number' || + !Number.isInteger(width) || + width < 1 || + width > 4_096 || + typeof height !== 'number' || + !Number.isInteger(height) || + height < 1 || + height > 4_096 + ) { + return undefined; + } + return Object.freeze([width, height]); + }; + const single = copyPair(sizes); + if (single) return single; + if (length === 0 || length > MAX_ACTIVE_SLOT_RECORDS) return undefined; + const copied: Array = []; + for (let index = 0; index < length; index += 1) { + const pair = copyPair(sizes[index] as unknown); + if (!pair) return undefined; + copied[copied.length] = pair; + } + return Object.freeze(copied); + } catch { + return undefined; } - return Object.freeze(copied); } function snapshotReplacementDefinition(input: unknown): GoogletagReplacementDefinition | undefined { @@ -535,23 +539,47 @@ export function createSlotService(options: SlotServiceOptions): SlotService { markSaturationOwner(physical); return; } + const confirmedKeys: string[] = []; try { - setWeakMapValue(quarantinedKeysByPhysical, physical.slot, physical.placementKeys); - if (weakMapValue(quarantinedKeysByPhysical, physical.slot) !== physical.placementKeys) { - throw new Error('quarantine publication failed'); + setWeakMapValue(quarantinedKeysByPhysical, physical.slot, confirmedKeys); + } catch { + if (weakMapValue(quarantinedKeysByPhysical, physical.slot) !== confirmedKeys) { + placementQuarantinePoisoned = true; + return; } - for (let index = 0; index < physical.placementKeys.length; index += 1) { - const key = physical.placementKeys[index]; - if (key === undefined) continue; - const previous = mapValue(placementQuarantine, key) ?? 0; - try { - setMapValue(placementQuarantine, key, previous + 1); - } catch (error) { - if (mapValue(placementQuarantine, key) !== previous + 1) throw error; - throw error; - } + } + if (weakMapValue(quarantinedKeysByPhysical, physical.slot) !== confirmedKeys) { + placementQuarantinePoisoned = true; + return; + } + for (let index = 0; index < physical.placementKeys.length; index += 1) { + const key = physical.placementKeys[index]; + if (key === undefined) continue; + const previous = mapValue(placementQuarantine, key) ?? 0; + let publicationThrew = false; + try { + setMapValue(placementQuarantine, key, previous + 1); + } catch { + publicationThrew = true; } - } catch { + const actual = mapValue(placementQuarantine, key) ?? 0; + if (actual === previous + 1) { + confirmedKeys[confirmedKeys.length] = key; + if (!publicationThrew) continue; + } else if (actual !== previous) { + placementQuarantinePoisoned = true; + return; + } + if (publicationThrew || actual === previous) { + // A hostile Map implementation either rejected this increment or threw + // after applying it. Retain only the increments whose ownership can be + // proven and fail closed until this physical slot is released. + markSaturationOwner(physical); + return; + } + } + if (weakMapValue(quarantinedKeysByPhysical, physical.slot) !== confirmedKeys) { + placementQuarantinePoisoned = true; markSaturationOwner(physical); } }; @@ -1556,7 +1584,14 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }; const requestBatch = (inputs: readonly SlotBatchRequestInput[]): readonly SlotRequestHandle[] => { - if (!Array.isArray(inputs) || inputs.length === 0 || inputs.length > MAX_ACTIVE_SLOT_RECORDS) { + let inputCount: number; + try { + if (!Array.isArray(inputs)) return Object.freeze([]); + inputCount = inputs.length; + } catch { + return Object.freeze([]); + } + if (inputCount === 0 || inputCount > MAX_ACTIVE_SLOT_RECORDS) { return Object.freeze([]); } const preparedInputs: SlotBatchRequestInput[] = []; @@ -1565,7 +1600,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const admittedPhysicalSlots = new Set(); let batchGeneration: object | undefined; try { - for (let index = 0; index < inputs.length; index += 1) { + for (let index = 0; index < inputCount; index += 1) { const input = inputs[index] as unknown; if (typeof input !== 'object' || input === null || Array.isArray(input)) { return Object.freeze([]); @@ -1626,12 +1661,27 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return Object.freeze([]); } deferInvocations = true; - let handles: SlotRequestHandle[]; + const handles: SlotRequestHandle[] = []; + let admissionFailed = false; try { - handles = preparedInputs.map((input) => request(input)); + for (let index = 0; index < preparedInputs.length; index += 1) { + const input = preparedInputs[index]; + if (!input) throw new Error('missing prepared SRA input'); + handles[handles.length] = request(input); + } + } catch { + admissionFailed = true; + for (let index = handles.length - 1; index >= 0; index -= 1) { + try { + handles[index]?.dispose(); + } catch { + // Continue rolling back later admissions after one hostile owner callback. + } + } } finally { deferInvocations = false; } + if (admissionFailed) return Object.freeze([]); const intents: RequestIntent[] = []; for (const input of preparedInputs) { const state = mapValue(navigationStates, input.navigationGeneration); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index b99d0fc88..eaf09f880 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -516,6 +516,55 @@ describe('adapter-owned GPT replacement transaction', () => { 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(); @@ -562,6 +611,60 @@ describe('adapter-owned GPT replacement transaction', () => { ).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() }; @@ -1012,6 +1115,132 @@ describe('physical GPT cycles', () => { 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('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 }); @@ -2002,6 +2231,46 @@ describe('Task 11 adversarial ownership review', () => { ); }); + 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(); @@ -2758,6 +3027,73 @@ describe('Task 11 adversarial ownership review', () => { }); }); + 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; From 1538666cf2f1acbf85f7089f0052b4d1616604dc Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:24:54 -0700 Subject: [PATCH 034/194] Contain poisoned batch iterators --- .../lib/src/services/slots.ts | 40 +++++++++---- .../lib/test/services/slots.test.ts | 56 +++++++++++++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 3c658bae8..12f9d2296 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -1683,7 +1683,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } if (admissionFailed) return Object.freeze([]); const intents: RequestIntent[] = []; - for (const input of preparedInputs) { + for (let index = 0; index < preparedInputs.length; index += 1) { + const input = preparedInputs[index]; + if (!input) continue; const state = mapValue(navigationStates, input.navigationGeneration); const record = state ? mapValue(state.records, input.registeredSlotId) : undefined; const intent = record?.activeIntent; @@ -1692,7 +1694,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } if (intents.length > 0) { const slots: object[] = []; - for (const intent of intents) { + for (let index = 0; index < intents.length; index += 1) { + const intent = intents[index]; + if (!intent) continue; const physical = intent.record.physical; if (!physical || physical.state !== 'live' || physical.activeCycle) { settle(intent, failed('slot_unresolved')); @@ -1756,21 +1760,30 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (!allIntentsAdmitted()) { throw new Error('SRA admission changed before invocation'); } - for (const intent of intents) { + for (let index = 0; index < intents.length; index += 1) { + const intent = intents[index]; + if (!intent) continue; if (!intent.terminal) armRequestDeadline(intent); } gpt.refresh(slots, Object.freeze({ changeCorrelator: false })); }); } catch { - for (const intent of intents) failExternalInvocation(intent.record, intent); + for (let index = 0; index < intents.length; index += 1) { + const intent = intents[index]; + if (intent) failExternalInvocation(intent.record, intent); + } return; } const liveIntents: RequestIntent[] = []; - for (const intent of intents) { + for (let index = 0; index < intents.length; index += 1) { + const intent = intents[index]; + if (!intent) continue; if (!intent.terminal) liveIntents[liveIntents.length] = intent; } let remaining = liveIntents.length; - for (const intent of liveIntents) { + for (let index = 0; index < liveIntents.length; index += 1) { + const intent = liveIntents[index]; + if (!intent) continue; let released = false; intent.invocation = { dispose: (): void => { @@ -1785,19 +1798,26 @@ export function createSlotService(options: SlotServiceOptions): SlotService { void operation.result.then( () => undefined, () => { - for (const intent of intents) failExternalInvocation(intent.record, intent); + for (let index = 0; index < intents.length; index += 1) { + const intent = intents[index]; + if (intent) failExternalInvocation(intent.record, intent); + } } ); }, () => { if (provisionalSubscriptions?.installed) provisionalSubscriptions.ownership.release(); - for (const intent of intents) failExternalInvocation(intent.record, intent); + for (let index = 0; index < intents.length; index += 1) { + const intent = intents[index]; + if (intent) failExternalInvocation(intent.record, intent); + } } ); } catch { if (provisionalSubscriptions?.installed) provisionalSubscriptions.ownership.release(); - for (const intent of intents) { - failExternalInvocation(intent.record, intent); + for (let index = 0; index < intents.length; index += 1) { + const intent = intents[index]; + if (intent) failExternalInvocation(intent.record, intent); } } } diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index eaf09f880..7d02acf3c 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -1201,6 +1201,62 @@ describe('physical GPT cycles', () => { 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; From 5695728292496c772df8d00b82832398b786225b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:58:53 -0700 Subject: [PATCH 035/194] Add bounded renderer reservation service --- .../lib/src/composition/browser.ts | 23 +- .../lib/src/integrations/aps/render.ts | 12 +- .../lib/src/kernel/sessions.ts | 38 + .../lib/src/services/reservations.ts | 915 ++++++++++++ .../lib/test/composition/browser.test.ts | 9 + .../lib/test/integrations/aps/render.test.ts | 12 + .../lib/test/kernel/sessions.test.ts | 21 + .../lib/test/services/reservations.test.ts | 1265 +++++++++++++++++ 8 files changed, 2290 insertions(+), 5 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/services/reservations.ts create mode 100644 crates/trusted-server-js/lib/test/services/reservations.test.ts diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 3faece603..7b4122615 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -17,7 +17,11 @@ import { type PrebidAdapter, type PrebidGlobalTarget, } from '../adapters/prebid'; -import { parseBrowserAuctionProjectionV1 } from '../core/contracts/auction_projection'; +import { parseCacheFetchPolicyV1 } from '../core/config'; +import { + parseBidRenderSourceV1, + parseBrowserAuctionProjectionV1, +} from '../core/contracts/auction_projection'; import { validateApsRenderer } from '../core/contracts/aps_renderer'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; @@ -30,6 +34,7 @@ import { type PageBidsController, prepareInitialAuctionProjection, } from '../services/projections'; +import { createReservationService, type ReservationService } from '../services/reservations'; import { createSlotService, type SlotService } from '../services/slots'; import { createTargetingService, type TargetingService } from '../services/targeting'; @@ -44,6 +49,7 @@ export interface BrowserComposition { } export interface BrowserServices { + readonly reservations: ReservationService; readonly slots: SlotService; readonly targeting: TargetingService; } @@ -70,6 +76,8 @@ export interface BrowserRuntimeComposition extends BrowserComposition { 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; } export interface BrowserCoreActivations { @@ -181,6 +189,8 @@ export function createTestBrowserRuntimeComposition( ...runtimeOptions, activateOwner: (context) => { const boot = context.boot as unknown as AcceptedBrowserBoot; + const cachePolicy = + boot.cachePolicy === undefined ? undefined : parseCacheFetchPolicyV1(boot.cachePolicy); const parseProjection = (candidate: unknown): object | undefined => parseBrowserAuctionProjectionV1(candidate, boot.cachePolicy); const initialProjection = prepareInitialAuctionProjection( @@ -190,7 +200,14 @@ export function createTestBrowserRuntimeComposition( if (!initialProjection) throw new Error('Accepted boot projection is unavailable'); const slotService = createSlotService({ googletag: composition.adapters.googletag }); const targetingService = createTargetingService(); - const services = Object.freeze({ slots: slotService, targeting: targetingService }); + const reservationService = createReservationService({ + prepareRenderSource: (candidate) => parseBidRenderSourceV1(candidate, cachePolicy), + }); + const services = Object.freeze({ + reservations: reservationService, + slots: slotService, + targeting: targetingService, + }); const session = createRuntimeSession({ createIdentityIssuer: compositionOptions.createIdentityIssuerForTest ?? createBrowserNavigationIdentityIssuer, @@ -198,6 +215,7 @@ export function createTestBrowserRuntimeComposition( }); context.onDispose(() => { session.dispose(); + reservationService.dispose(); slotService.dispose(); targetingService.dispose(); composition.adapters.googletag.dispose(); @@ -264,5 +282,6 @@ export function createTestBrowserRuntimeComposition( auctionContextRegistryForTest: () => auctionContextRegistry, slotServiceForTest: () => browserServices?.slots, targetingServiceForTest: () => browserServices?.targeting, + reservationServiceForTest: () => browserServices?.reservations, }); } 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 e17db2575..cb75a3024 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -1,5 +1,5 @@ import { log } from '../../core/log'; -import type { ApsPrebidRendererEntry, TsjsApi } from '../../core/types'; +import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types'; import { validateApsRenderer } from '../../core/contracts/aps_renderer'; export { parseApsRendererDescriptor, validateApsRenderer } from '../../core/contracts/aps_renderer'; @@ -19,6 +19,12 @@ const DEFAULT_PREBID_RENDERER_TTL_SECONDS = 300; const MAX_PREBID_RENDERER_TTL_SECONDS = 3600; const MAX_PREBID_ID_BYTES = 1024; +/** Validate, copy, and freeze one APS tagged render source. */ +export function prepareApsRenderSource(input: unknown): Readonly | undefined { + const renderer = validateApsRenderer(input); + return renderer ? Object.freeze(renderer) : undefined; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -70,7 +76,7 @@ export function registerApsPrebidRenderer( ) { return false; } - const renderer = validateApsRenderer(input); + const renderer = prepareApsRenderSource(input); if (!renderer) return false; const now = Date.now(); @@ -165,7 +171,7 @@ export interface RenderApsCreativeOptions { /** 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 renderer = prepareApsRenderSource(input); const rendererUrl = apsRendererUrl(); const nonce = createNonce(); if (!renderer || !rendererUrl || !nonce) { diff --git a/crates/trusted-server-js/lib/src/kernel/sessions.ts b/crates/trusted-server-js/lib/src/kernel/sessions.ts index dfb24da2b..0bc66e758 100644 --- a/crates/trusted-server-js/lib/src/kernel/sessions.ts +++ b/crates/trusted-server-js/lib/src/kernel/sessions.ts @@ -1,6 +1,11 @@ import { DisposableStack, type DisposeCallback, type DisposalErrorHandler } from './disposable'; import type { IdentityGenerationResult, NavigationIdentityIssuer } from './identity'; +/** Immutable price authority transferred from winner admission into one attempt. */ +export interface WinnerContext { + readonly selectedCpm: number; +} + /** Factory that obtains one fresh eight-byte identity prefix per navigation. */ export type NavigationIdentityIssuerFactory = () => IdentityGenerationResult; @@ -114,12 +119,14 @@ export interface RenderAttemptScope { readonly interfaces: RuntimeInterfaces; readonly id: string; readonly slot: string; + readonly winnerContext: WinnerContext | undefined; readonly disposed: boolean; readonly signal: AbortSignal; readonly capture: ( callback: (...arguments_: Arguments) => unknown ) => (...arguments_: Arguments) => boolean; readonly isCurrent: () => boolean; + readonly adoptWinnerContext: (context: WinnerContext) => boolean; readonly onDispose: (kind: string, callback: DisposeCallback) => void; readonly dispose: () => void; } @@ -203,6 +210,7 @@ class OwnerScope { class RenderAttemptOwner implements RenderAttemptScope { private readonly scope: OwnerScope; + private acceptedWinnerContext: WinnerContext | undefined; public constructor( public readonly id: string, @@ -226,6 +234,36 @@ class RenderAttemptOwner implements RenderAttemptScope { return this.scope.signal; } + public get winnerContext(): WinnerContext | undefined { + return this.acceptedWinnerContext; + } + + public adoptWinnerContext(context: WinnerContext): boolean { + if (!this.isCurrent()) return false; + if (this.acceptedWinnerContext !== undefined) return this.acceptedWinnerContext === context; + try { + const descriptor = Object.getOwnPropertyDescriptor(context, 'selectedCpm'); + if ( + !Object.isFrozen(context) || + Object.getPrototypeOf(context) !== Object.prototype || + Object.getOwnPropertyNames(context).length !== 1 || + Object.getOwnPropertySymbols(context).length !== 0 || + !descriptor || + !descriptor.enumerable || + !('value' in descriptor) || + typeof descriptor.value !== 'number' || + !Number.isFinite(descriptor.value) || + descriptor.value < 0 + ) { + return false; + } + this.acceptedWinnerContext = context; + return true; + } catch { + return false; + } + } + public capture( callback: (...arguments_: Arguments) => unknown ): (...arguments_: Arguments) => boolean { diff --git a/crates/trusted-server-js/lib/src/services/reservations.ts b/crates/trusted-server-js/lib/src/services/reservations.ts new file mode 100644 index 000000000..e885e1994 --- /dev/null +++ b/crates/trusted-server-js/lib/src/services/reservations.ts @@ -0,0 +1,915 @@ +import type { WinnerContext } from '../kernel/sessions'; + +export const RENDER_RESERVATION_LIFETIME_MS = 15 * 60 * 1_000; +export const PREBID_ADMISSION_LEASE_MS = 10_000; + +const RESERVATION_ID = /^r1_[A-Za-z0-9_-]{22}$/; +const ATTEMPT_ID = /^a1_[A-Za-z0-9_-]{22}$/; +const AUCTION_ID = /^[A-Za-z0-9._:-]{1,128}$/; +const MAX_RESERVATIONS = 320; +const textEncoder = new TextEncoder(); + +const mapDeleteIntrinsic = Map.prototype.delete; +const mapEntriesIntrinsic = Map.prototype.entries; +const mapGetIntrinsic = Map.prototype.get; +const mapSetIntrinsic = Map.prototype.set; +const mapValuesIntrinsic = Map.prototype.values; +const mapIteratorNextIntrinsic = Object.getPrototypeOf(new Map().values()).next as ( + this: IterableIterator +) => IteratorResult; +const mapEntryIteratorNextIntrinsic = Object.getPrototypeOf(new Map().entries()).next as ( + this: IterableIterator +) => IteratorResult; +const mapSizeGetter = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.get as ( + this: Map +) => number; +const performanceNowIntrinsic = performance.now; + +function mapValue(map: Map, key: Key): Value | undefined { + return Reflect.apply(mapGetIntrinsic, map, [key]) as Value | undefined; +} + +function setMapValue(map: Map, key: Key, value: Value): void { + Reflect.apply(mapSetIntrinsic, map, [key, value]); +} + +function deleteMapValue(map: Map, key: Key): boolean { + return Reflect.apply(mapDeleteIntrinsic, map, [key]) as boolean; +} + +function mapSize(map: Map): number { + return Reflect.apply(mapSizeGetter, map, []) as number; +} + +function mapValueSnapshot(map: Map): Value[] { + const iterator = Reflect.apply(mapValuesIntrinsic, map, []) as IterableIterator; + const values: Value[] = []; + while (true) { + const step = Reflect.apply(mapIteratorNextIntrinsic, iterator, []) as IteratorResult; + if (step.done) return values; + values[values.length] = step.value; + } +} + +function entrySnapshot(map: Map): [Key, Value][] { + const iterator = Reflect.apply(mapEntriesIntrinsic, map, []) as IterableIterator<[Key, Value]>; + const values: [Key, Value][] = []; + while (true) { + const step = Reflect.apply(mapEntryIteratorNextIntrinsic, iterator, []) as IteratorResult< + [Key, Value] + >; + if (step.done) return values; + values[values.length] = step.value; + } +} + +function ownDataRecord( + 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 = Object.getOwnPropertyNames(value); + if (names.length !== expectedKeys.length) return undefined; + for (let expectedIndex = 0; expectedIndex < expectedKeys.length; expectedIndex += 1) { + const expected = expectedKeys[expectedIndex]; + let found = false; + for (let nameIndex = 0; nameIndex < names.length; nameIndex += 1) { + if (names[nameIndex] === expected) { + found = true; + break; + } + } + if (!found) return undefined; + } + const output: 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; + output[name] = descriptor.value; + } + return output; + } catch { + return undefined; + } +} + +function frozenResult(value: Value): Readonly { + return Object.freeze(value); +} + +function validBoundedString(value: unknown, maximumBytes: number): value is string { + if (typeof value !== 'string' || value.length === 0) return false; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return false; + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) return false; + } + return textEncoder.encode(value).length <= maximumBytes; +} + +function copyTaggedRenderSource(value: unknown): ReservationRenderSource | 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 = Object.getOwnPropertyNames(value); + const output: 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; + if (typeof descriptor.value !== 'string' && typeof descriptor.value !== 'number') { + return undefined; + } + Object.defineProperty(output, name, { + configurable: true, + enumerable: true, + value: descriptor.value, + writable: true, + }); + } + if ( + (output.type !== 'aps' && output.type !== 'adm' && output.type !== 'cache') || + output.version !== 1 + ) { + return undefined; + } + return Object.freeze(output) as ReservationRenderSource; + } catch { + return undefined; + } +} + +export interface ReservationOwner { + readonly generation: object; + readonly isCurrent: () => boolean; + readonly onDispose: (kind: string, callback: () => void) => void; +} + +export interface ReservationAttempt { + readonly id: string; + readonly slot: string; + readonly winnerContext: WinnerContext | undefined; + readonly isCurrent: () => boolean; + readonly adoptWinnerContext: (context: WinnerContext) => boolean; +} + +export interface ReservationServiceOptions { + readonly now?: () => number; + readonly prepareRenderSource: (candidate: unknown) => ReservationRenderSource | undefined; +} + +/** Tagged browser render source copied after an injected exact parser accepts it. */ +export type ReservationRenderSource = Readonly<{ + type: 'aps' | 'adm' | 'cache'; + version: 1; +}> & + Readonly; + +export interface ReservationRegistrationInput { + readonly reservationId: unknown; + readonly slot: unknown; + readonly navigation: ReservationOwner; + readonly attemptId: unknown; + readonly renderSource: unknown; + readonly winnerContext: unknown; +} + +export interface PrebidLeaseRegistrationInput { + readonly reservationId: unknown; + readonly slot: unknown; + readonly navigation: ReservationOwner; + readonly auctionId: unknown; + readonly adUnitCode: unknown; + readonly renderSource: unknown; + readonly winnerContext: unknown; + readonly prebidBid: unknown; +} + +export type ReservationRegistrationResult = + | Readonly<{ ok: true; expiresAt: number }> + | Readonly<{ + ok: false; + reason: + | 'invalid_reservation_id' + | 'invalid_slot' + | 'invalid_render_source' + | 'invalid_winner_context' + | 'invalid_attempt' + | 'prebid_cpm_mismatch' + | 'reservation_collision' + | 'reservation_not_live' + | 'registry_full' + | 'service_disposed' + | 'stale_owner'; + }>; + +export type ReservationTombstoneState = + | 'aborted' + | 'consumed' + | 'disposed' + | 'prebid_admission_failed' + | 'prebid_contract_violation' + | 'prebid_selection_timeout' + | 'stale' + | 'unselected'; + +export type ReservationState = + 'awaiting_prebid_selection' | 'renderable' | ReservationTombstoneState; + +export type ReservationRecognition = + | Readonly<{ recognized: false }> + | Readonly<{ recognized: true; state: ReservationState; expiresAt: number }>; + +export interface PromotePrebidSelectionInput { + readonly reservationId: unknown; + readonly auctionId: unknown; + readonly adUnitCode: unknown; + readonly navigationGeneration: object; + readonly attempt: ReservationAttempt; + readonly prebidBid: unknown; +} + +export interface ReservationClaimInput { + readonly reservationId: unknown; + readonly slot: unknown; + readonly navigationGeneration: object; + readonly attempt: ReservationAttempt; + readonly pucSource: unknown; +} + +export interface ReservationTombstoneInput { + readonly reservationId: unknown; + readonly slot: unknown; + readonly navigationGeneration: object; + readonly attemptId: unknown; +} + +export interface PrebidGroupOwnerInput { + readonly auctionId: unknown; + readonly adUnitCode: unknown; + readonly navigationGeneration: object; +} + +export interface PrebidLeaseOwnerInput extends PrebidGroupOwnerInput { + readonly reservationId: unknown; +} + +export type ReservationClaimResult = + | Readonly<{ recognized: false }> + | Readonly<{ recognized: true; claimed: false; state: ReservationState }> + | Readonly<{ + recognized: true; + claimed: true; + renderSource: ReservationRenderSource; + winnerContext: WinnerContext; + pucSource: object; + expiresAt: number; + }>; + +export interface ReservationServiceInventory { + readonly disposed: boolean; + readonly size: number; + readonly live: number; + readonly tombstones: number; + readonly entriesWithRenderSource: number; + readonly entriesWithWinnerContext: number; + readonly entriesWithPucSource: number; +} + +export interface ReservationService { + readonly registerRender: (input: ReservationRegistrationInput) => ReservationRegistrationResult; + readonly registerPrebidLease: ( + input: PrebidLeaseRegistrationInput + ) => ReservationRegistrationResult; + readonly promotePrebidSelection: ( + input: PromotePrebidSelectionInput + ) => ReservationRegistrationResult; + readonly claim: (input: ReservationClaimInput) => ReservationClaimResult; + readonly recognize: (reservationId: unknown) => ReservationRecognition; + readonly tombstone: (input: ReservationTombstoneInput, state: 'disposed' | 'stale') => boolean; + readonly tombstonePrebidGroup: ( + input: PrebidGroupOwnerInput, + state: 'aborted' | 'prebid_selection_timeout' + ) => number; + readonly tombstonePrebidLease: ( + input: PrebidLeaseOwnerInput, + state: 'prebid_admission_failed' | 'prebid_contract_violation' + ) => boolean; + readonly dispose: () => void; + readonly snapshotInventoryForTest: () => ReservationServiceInventory; +} + +interface LiveReservation { + readonly reservationId: string; + readonly slot: string; + readonly navigationGeneration: object; + readonly renderSource: ReservationRenderSource; + readonly winnerContext: WinnerContext; + readonly ownerToken: object; + expiresAt: number; + state: 'awaiting_prebid_selection' | 'renderable'; + attemptId: string | undefined; + auctionId: string | undefined; + adUnitCode: string | undefined; + busy: boolean; + pucSource: object | undefined; +} + +interface ReservationTombstone { + readonly expiresAt: number; + readonly state: ReservationTombstoneState; +} + +type ReservationEntry = LiveReservation | ReservationTombstone; + +interface OwnerSnapshot { + readonly generation: object; + readonly isCurrent: () => boolean; + readonly onDispose: (kind: string, callback: () => void) => void; + readonly readGeneration: () => object | undefined; +} + +function liveEntry(entry: ReservationEntry): entry is LiveReservation { + return entry.state === 'awaiting_prebid_selection' || entry.state === 'renderable'; +} + +function ownerDisposalState(entry: LiveReservation): 'aborted' | 'disposed' { + return entry.state === 'awaiting_prebid_selection' ? 'aborted' : 'disposed'; +} + +function winnerContext(value: unknown): WinnerContext | undefined { + const record = ownDataRecord(value, ['selectedCpm']); + if ( + !record || + typeof record.selectedCpm !== 'number' || + !Number.isFinite(record.selectedCpm) || + record.selectedCpm < 0 + ) { + return undefined; + } + return frozenResult({ selectedCpm: record.selectedCpm }); +} + +function prebidCpmMatches(value: unknown, context: WinnerContext): boolean { + try { + if ( + (typeof value !== 'object' && typeof value !== 'function') || + value === null || + !Object.isFrozen(value) + ) { + return false; + } + const descriptor = Object.getOwnPropertyDescriptor(value, 'cpm'); + return ( + !!descriptor && 'value' in descriptor && Object.is(descriptor.value, context.selectedCpm) + ); + } catch { + return false; + } +} + +function ownerSnapshot(value: unknown): OwnerSnapshot | undefined { + try { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + return undefined; + } + const owner = value as ReservationOwner; + const generation = owner.generation; + const isCurrentMethod = owner.isCurrent; + const onDisposeMethod = owner.onDispose; + if ( + (typeof generation !== 'object' && typeof generation !== 'function') || + generation === null || + typeof isCurrentMethod !== 'function' || + typeof onDisposeMethod !== 'function' + ) { + return undefined; + } + return { + generation, + isCurrent: () => Reflect.apply(isCurrentMethod, value, []) as boolean, + onDispose: (kind, callback) => { + Reflect.apply(onDisposeMethod, value, [kind, callback]); + }, + readGeneration: () => { + try { + const current = (value as ReservationOwner).generation; + return (typeof current === 'object' || typeof current === 'function') && current !== null + ? current + : undefined; + } catch { + return undefined; + } + }, + }; + } catch { + return undefined; + } +} + +function currentOwner(owner: OwnerSnapshot): boolean { + try { + return owner.isCurrent() === true; + } catch { + return false; + } +} + +function currentAttempt(attempt: ReservationAttempt): boolean { + try { + return attempt.isCurrent() === true; + } catch { + return false; + } +} + +function attemptIdentity(attempt: ReservationAttempt): { id: string; slot: string } | undefined { + try { + const id = attempt.id; + const slot = attempt.slot; + if (!ATTEMPT_ID.test(id) || !validBoundedString(slot, 256)) return undefined; + return { id, slot }; + } catch { + return undefined; + } +} + +function monotonicClock(source: () => number): () => number | undefined { + let last = Number.NEGATIVE_INFINITY; + return (): number | undefined => { + try { + const value = source(); + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined; + last = Math.max(last, value); + return last; + } catch { + return undefined; + } + }; +} + +function fixedExpiry(now: number, lifetime: number): number | undefined { + const expiresAt = now + lifetime; + return Number.isFinite(expiresAt) && expiresAt > now ? expiresAt : undefined; +} + +function defaultNow(): number { + return Reflect.apply(performanceNowIntrinsic, performance, []) as number; +} + +/** Whether a candidate is one exact server-minted renderer reservation id. */ +export function isRendererReservationId(value: unknown): value is string { + return typeof value === 'string' && RESERVATION_ID.test(value); +} + +/** Construct the runtime-owned renderer reservation service. */ +export function createReservationService(options: ReservationServiceOptions): ReservationService { + let nowSource: () => number = defaultNow; + let prepareRenderSource: ReservationServiceOptions['prepareRenderSource'] | undefined; + let disposed = false; + try { + if (options.now !== undefined) { + if (typeof options.now !== 'function') disposed = true; + else nowSource = options.now; + } + if (typeof options.prepareRenderSource !== 'function') disposed = true; + else prepareRenderSource = options.prepareRenderSource; + } catch { + disposed = true; + } + const readNow = monotonicClock(nowSource); + const entries = new Map(); + + const disposeStore = (): void => { + disposed = true; + const snapshot = entrySnapshot(entries); + for (let index = 0; index < snapshot.length; index += 1) { + const pair = snapshot[index]; + if (pair) deleteMapValue(entries, pair[0]); + } + }; + + const prune = (now: number): void => { + const snapshot = entrySnapshot(entries); + for (let index = 0; index < snapshot.length; index += 1) { + const pair = snapshot[index]; + if (!pair) continue; + const id = pair[0]; + const entry = pair[1]; + if (entry.expiresAt <= now) { + if (mapValue(entries, id) === entry) deleteMapValue(entries, id); + } + } + }; + + const clock = (): number | undefined => { + if (disposed) return undefined; + const now = readNow(); + if (now === undefined) { + disposeStore(); + return undefined; + } + prune(now); + return now; + }; + + const replaceWithTombstone = ( + reservationId: string, + expected: LiveReservation, + state: ReservationTombstoneState + ): boolean => { + if (mapValue(entries, reservationId) !== expected) return false; + setMapValue(entries, reservationId, frozenResult({ expiresAt: expected.expiresAt, state })); + return true; + }; + + const failure = ( + reason: Extract['reason'] + ): ReservationRegistrationResult => frozenResult({ ok: false, reason }); + + const register = (candidate: unknown, prebid: boolean): ReservationRegistrationResult => { + const keys = prebid + ? [ + 'reservationId', + 'slot', + 'navigation', + 'auctionId', + 'adUnitCode', + 'renderSource', + 'winnerContext', + 'prebidBid', + ] + : ['reservationId', 'slot', 'navigation', 'attemptId', 'renderSource', 'winnerContext']; + const input = ownDataRecord(candidate, keys); + if (!input || !isRendererReservationId(input.reservationId)) { + return failure('invalid_reservation_id'); + } + if (!validBoundedString(input.slot, 256)) return failure('invalid_slot'); + const context = winnerContext(input.winnerContext); + if (!context) return failure('invalid_winner_context'); + let renderSource: ReservationRenderSource | undefined; + try { + renderSource = copyTaggedRenderSource(prepareRenderSource?.(input.renderSource)); + } catch { + renderSource = undefined; + } + if (!renderSource) return failure('invalid_render_source'); + const owner = ownerSnapshot(input.navigation); + if (!owner || !currentOwner(owner)) return failure('stale_owner'); + + let attemptId: string | undefined; + let auctionId: string | undefined; + let adUnitCode: string | undefined; + if (prebid) { + if ( + typeof input.auctionId !== 'string' || + !AUCTION_ID.test(input.auctionId) || + !validBoundedString(input.adUnitCode, 256) || + input.adUnitCode !== input.slot || + !prebidCpmMatches(input.prebidBid, context) + ) { + return failure('prebid_cpm_mismatch'); + } + auctionId = input.auctionId; + adUnitCode = input.adUnitCode; + } else { + if (typeof input.attemptId !== 'string' || !ATTEMPT_ID.test(input.attemptId)) { + return failure('invalid_attempt'); + } + attemptId = input.attemptId; + } + + const now = clock(); + if (now === undefined) return failure('service_disposed'); + const expiresAt = fixedExpiry( + now, + prebid ? PREBID_ADMISSION_LEASE_MS : RENDER_RESERVATION_LIFETIME_MS + ); + if (expiresAt === undefined) { + disposeStore(); + return failure('service_disposed'); + } + if (mapValue(entries, input.reservationId) !== undefined) { + return failure('reservation_collision'); + } + if (mapSize(entries) >= MAX_RESERVATIONS) return failure('registry_full'); + const entry: LiveReservation = { + reservationId: input.reservationId, + slot: input.slot, + navigationGeneration: owner.generation, + renderSource, + winnerContext: context, + ownerToken: Object.freeze({}), + expiresAt, + state: prebid ? 'awaiting_prebid_selection' : 'renderable', + attemptId, + auctionId, + adUnitCode, + busy: false, + pucSource: undefined, + }; + setMapValue(entries, input.reservationId, entry); + const publishedReservationId = input.reservationId; + const publishedOwnerToken = entry.ownerToken; + try { + owner.onDispose('reservation', () => { + const current = mapValue(entries, publishedReservationId); + if (current && liveEntry(current) && current.ownerToken === publishedOwnerToken) { + replaceWithTombstone(publishedReservationId, current, ownerDisposalState(current)); + } + }); + if ( + mapValue(entries, input.reservationId) !== entry || + !currentOwner(owner) || + owner.readGeneration() !== entry.navigationGeneration + ) { + replaceWithTombstone(input.reservationId, entry, ownerDisposalState(entry)); + return failure('stale_owner'); + } + } catch { + replaceWithTombstone(input.reservationId, entry, ownerDisposalState(entry)); + return failure('stale_owner'); + } + return frozenResult({ ok: true, expiresAt: entry.expiresAt }); + }; + + const recognize = (reservationId: unknown): ReservationRecognition => { + if (clock() === undefined || typeof reservationId !== 'string') { + return frozenResult({ recognized: false }); + } + const entry = mapValue(entries, reservationId); + return entry + ? frozenResult({ recognized: true, state: entry.state, expiresAt: entry.expiresAt }) + : frozenResult({ recognized: false }); + }; + + const refusedClaim = (state: ReservationState): ReservationClaimResult => + frozenResult({ recognized: true as const, claimed: false as const, state }); + + const service: ReservationService = { + registerRender: (input) => register(input, false), + registerPrebidLease: (input) => register(input, true), + promotePrebidSelection(input): ReservationRegistrationResult { + const fields = ownDataRecord(input, [ + 'reservationId', + 'auctionId', + 'adUnitCode', + 'navigationGeneration', + 'attempt', + 'prebidBid', + ]); + const now = clock(); + if (now === undefined) return failure('service_disposed'); + if (!fields || typeof fields.reservationId !== 'string') { + return failure('reservation_not_live'); + } + const entry = mapValue(entries, fields.reservationId); + if ( + !entry || + !liveEntry(entry) || + entry.state !== 'awaiting_prebid_selection' || + entry.busy || + fields.auctionId !== entry.auctionId || + fields.adUnitCode !== entry.adUnitCode || + fields.navigationGeneration !== entry.navigationGeneration || + !prebidCpmMatches(fields.prebidBid, entry.winnerContext) + ) { + return failure('reservation_not_live'); + } + const promotedExpiry = fixedExpiry(now, RENDER_RESERVATION_LIFETIME_MS); + if (promotedExpiry === undefined) { + disposeStore(); + return failure('service_disposed'); + } + const attempt = fields.attempt as ReservationAttempt; + const identity = attemptIdentity(attempt); + if (!identity || identity.slot !== entry.slot || !currentAttempt(attempt)) { + return failure('invalid_attempt'); + } + entry.busy = true; + let adopted: boolean; + try { + adopted = attempt.adoptWinnerContext(entry.winnerContext) === true; + } catch { + adopted = false; + } + if ( + !adopted || + mapValue(entries, fields.reservationId) !== entry || + !currentAttempt(attempt) + ) { + if (mapValue(entries, fields.reservationId) === entry) entry.busy = false; + return failure('invalid_attempt'); + } + entry.attemptId = identity.id; + entry.state = 'renderable'; + entry.expiresAt = promotedExpiry; + entry.busy = false; + const candidates = mapValueSnapshot(entries); + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + if ( + candidate && + candidate !== entry && + liveEntry(candidate) && + candidate.state === 'awaiting_prebid_selection' && + candidate.auctionId === entry.auctionId && + candidate.adUnitCode === entry.adUnitCode && + candidate.navigationGeneration === entry.navigationGeneration + ) { + replaceWithTombstone(candidate.reservationId, candidate, 'unselected'); + } + } + return frozenResult({ ok: true, expiresAt: entry.expiresAt }); + }, + claim(input): ReservationClaimResult { + const minimalId = (() => { + try { + return input.reservationId; + } catch { + return undefined; + } + })(); + if (clock() === undefined || typeof minimalId !== 'string') { + return frozenResult({ recognized: false }); + } + const entry = mapValue(entries, minimalId); + if (!entry) return frozenResult({ recognized: false }); + if (!liveEntry(entry)) { + return refusedClaim(entry.state); + } + if (entry.busy) { + return refusedClaim(entry.state); + } + if (entry.state === 'awaiting_prebid_selection') { + replaceWithTombstone(minimalId, entry, 'prebid_contract_violation'); + return refusedClaim('prebid_contract_violation'); + } + const fields = ownDataRecord(input, [ + 'reservationId', + 'slot', + 'navigationGeneration', + 'attempt', + 'pucSource', + ]); + if (!fields) { + return refusedClaim(entry.state); + } + const attempt = fields.attempt as ReservationAttempt; + const identity = attemptIdentity(attempt); + if ( + !identity || + fields.slot !== entry.slot || + fields.navigationGeneration !== entry.navigationGeneration || + identity.id !== entry.attemptId || + identity.slot !== entry.slot || + typeof fields.pucSource !== 'object' || + fields.pucSource === null || + !currentAttempt(attempt) + ) { + return refusedClaim(entry.state); + } + entry.busy = true; + entry.pucSource = fields.pucSource; + let adopted: boolean; + try { + adopted = attempt.adoptWinnerContext(entry.winnerContext) === true; + } catch { + adopted = false; + } + if (!adopted || mapValue(entries, minimalId) !== entry || !currentAttempt(attempt)) { + if (mapValue(entries, minimalId) === entry) { + entry.pucSource = undefined; + entry.busy = false; + return refusedClaim(entry.state); + } + const replacement = mapValue(entries, minimalId); + return refusedClaim(replacement?.state ?? 'stale'); + } + const result = frozenResult({ + recognized: true as const, + claimed: true as const, + renderSource: entry.renderSource, + winnerContext: entry.winnerContext, + pucSource: entry.pucSource, + expiresAt: entry.expiresAt, + }); + replaceWithTombstone(minimalId, entry, 'consumed'); + return result; + }, + recognize, + tombstone(input, state): boolean { + const fields = ownDataRecord(input, [ + 'reservationId', + 'slot', + 'navigationGeneration', + 'attemptId', + ]); + if (clock() === undefined || !fields || typeof fields.reservationId !== 'string') { + return false; + } + const entry = mapValue(entries, fields.reservationId); + if ( + !entry || + !liveEntry(entry) || + entry.state !== 'renderable' || + fields.slot !== entry.slot || + fields.navigationGeneration !== entry.navigationGeneration || + fields.attemptId !== entry.attemptId + ) { + return false; + } + return replaceWithTombstone(fields.reservationId, entry, state); + }, + tombstonePrebidLease(input, state): boolean { + const fields = ownDataRecord(input, [ + 'reservationId', + 'auctionId', + 'adUnitCode', + 'navigationGeneration', + ]); + if (clock() === undefined || !fields || typeof fields.reservationId !== 'string') { + return false; + } + const entry = mapValue(entries, fields.reservationId); + if ( + !entry || + !liveEntry(entry) || + entry.state !== 'awaiting_prebid_selection' || + entry.busy || + entry.auctionId !== fields.auctionId || + entry.adUnitCode !== fields.adUnitCode || + entry.navigationGeneration !== fields.navigationGeneration + ) { + return false; + } + return replaceWithTombstone(fields.reservationId, entry, state); + }, + tombstonePrebidGroup(input, state): number { + const fields = ownDataRecord(input, ['auctionId', 'adUnitCode', 'navigationGeneration']); + if (clock() === undefined || !fields) { + return 0; + } + let count = 0; + const candidates = mapValueSnapshot(entries); + for (let index = 0; index < candidates.length; index += 1) { + const entry = candidates[index]; + if ( + entry && + liveEntry(entry) && + entry.state === 'awaiting_prebid_selection' && + entry.auctionId === fields.auctionId && + entry.adUnitCode === fields.adUnitCode && + entry.navigationGeneration === fields.navigationGeneration && + replaceWithTombstone(entry.reservationId, entry, state) + ) { + count += 1; + } + } + return count; + }, + dispose(): void { + if (disposed) return; + disposeStore(); + }, + snapshotInventoryForTest(): ReservationServiceInventory { + let live = 0; + let tombstones = 0; + let entriesWithRenderSource = 0; + let entriesWithWinnerContext = 0; + let entriesWithPucSource = 0; + const snapshot = mapValueSnapshot(entries); + for (let index = 0; index < snapshot.length; index += 1) { + const entry = snapshot[index]; + if (!entry) continue; + if (liveEntry(entry)) { + live += 1; + entriesWithRenderSource += 1; + entriesWithWinnerContext += 1; + if (entry.pucSource !== undefined) entriesWithPucSource += 1; + } else tombstones += 1; + } + return frozenResult({ + disposed, + size: mapSize(entries), + live, + tombstones, + entriesWithRenderSource, + entriesWithWinnerContext, + entriesWithPucSource, + }); + }, + }; + return frozenResult(service); +} diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 114bd8b9e..788b089c8 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -361,10 +361,13 @@ describe('browser composition', () => { expect(composition.runtimeSessionForTest()).toBe(session); const slotService = composition.slotServiceForTest(); const targetingService = composition.targetingServiceForTest(); + const reservationService = composition.reservationServiceForTest(); expect(slotService).toBeDefined(); expect(targetingService).toBeDefined(); + expect(reservationService).toBeDefined(); expect(session?.interfaces['slots']).toBe(slotService); expect(session?.interfaces['targeting']).toBe(targetingService); + expect(session?.interfaces['reservations']).toBe(reservationService); expect(session?.currentNavigation?.interfaces).toBe(session?.interfaces); expect(session?.currentNavigation?.currentAuctionProjection).toEqual(projection); expect(Object.isFrozen(session?.currentNavigation?.currentAuctionProjection)).toBe(true); @@ -382,6 +385,7 @@ describe('browser composition', () => { if (!replacement?.ok) throw new Error('Expected SPA navigation'); expect(replacement.value.currentAuctionProjection).toBeUndefined(); expect(composition.runtimeSessionForTest()).toBe(session); + expect(composition.reservationServiceForTest()).toBe(reservationService); const pageBids = composition.pageBidsControllerForTest(); expect( @@ -406,8 +410,13 @@ describe('browser composition', () => { records: 0, }); expect(targetingService?.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + expect(reservationService?.snapshotInventoryForTest()).toMatchObject({ + disposed: true, + size: 0, + }); expect(composition.slotServiceForTest()).toBeUndefined(); expect(composition.targetingServiceForTest()).toBeUndefined(); + expect(composition.reservationServiceForTest()).toBeUndefined(); }); it('unwinds a lazily-created session when navigation identity generation fails', async () => { 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 49b2769cb..e8ba126be 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 @@ -13,6 +13,7 @@ import { apsRendererUrl, getApsPrebidRenderer, parseApsRendererDescriptor, + prepareApsRenderSource, registerApsPrebidRenderer, renderApsCreative, validateApsRenderer, @@ -271,6 +272,17 @@ function materializeCorpusVector(vector: CorpusVector): MaterializedCorpusVector } 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('matches every shared cross-language contract vector', () => { for (const vector of rendererCorpus.vectors.map(materializeCorpusVector)) { const actual = classifyApsRendererV1(vector.descriptor, vector.publisherOrigin); diff --git a/crates/trusted-server-js/lib/test/kernel/sessions.test.ts b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts index f958acd03..c39829679 100644 --- a/crates/trusted-server-js/lib/test/kernel/sessions.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts @@ -321,6 +321,27 @@ describe('runtime and navigation sessions', () => { }); }); + it('adopts one immutable winner context and rejects replacement or stale adoption', () => { + 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(); + expect(attempt.value.adoptWinnerContext(accepted)).toBe(true); + expect(attempt.value.winnerContext).toBe(accepted); + expect(attempt.value.adoptWinnerContext(accepted)).toBe(true); + expect(attempt.value.adoptWinnerContext(Object.freeze({ selectedCpm: 1.25 }))).toBe(false); + + attempt.value.dispose(); + expect(attempt.value.adoptWinnerContext(Object.freeze({ selectedCpm: 2 }))).toBe(false); + expect(attempt.value.winnerContext).toBe(accepted); + }); + it('refuses identity failure before replacing or creating route work', () => { const firstIssuer = identityFactory(); const createIdentityIssuer = vi 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..3da985f48 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -0,0 +1,1265 @@ +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, +} 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'); + expect(result.renderSource).toEqual(source); + expect(result.renderSource).not.toBe(mutable); + expect(Object.isFrozen(result.renderSource)).toBe(true); + expect(Object.isFrozen(result.winnerContext)).toBe(true); + } + }); + + 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, + adoptWinnerContext: (context) => { + adopted = context; + return true; + }, + }, + pucSource: Object.freeze({}), + }) + ).toMatchObject({ claimed: true }); + } finally { + Map.prototype.get = originalGet; + Map.prototype.set = originalSet; + Map.prototype.delete = originalDelete; + } + }); + + 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('makes a late expired owner callback token-safe after the same id is reused', () => { + 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 })).toMatchObject({ ok: true }); + oldCleanup?.(); + + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + }); +}); + +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('never moves the monotonic clock backward', () => { + let now = 100; + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + expect(registerRender(service, navigation, attempt)).toEqual({ + ok: true, + expiresAt: 100 + RENDER_RESERVATION_LIFETIME_MS, + }); + + now = 1; + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state: 'renderable', + expiresAt: 100 + RENDER_RESERVATION_LIFETIME_MS, + }); + }); + + 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('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, + }); + }); +}); + +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('keeps a ten-second suppress-only lease, then atomically promotes the selected id to 15 minutes', () => { + let now = 10; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + const bid = Object.freeze({ cpm: 1.25 }); + const base = { + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1.25 }, + prebidBid: bid, + }; + expect(service.registerPrebidLease({ ...base, reservationId: reservationId(1) })).toEqual({ + ok: true, + expiresAt: 10 + PREBID_ADMISSION_LEASE_MS, + }); + expect(service.registerPrebidLease({ ...base, reservationId: reservationId(2) })).toMatchObject( + { + ok: true, + } + ); + const attempt = renderAttempt(navigation); + now = 1_000; + + expect( + service.promotePrebidSelection({ + reservationId: reservationId(1), + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + attempt, + prebidBid: bid, + }) + ).toEqual({ ok: true, expiresAt: 1_000 + RENDER_RESERVATION_LIFETIME_MS }); + expect(attempt.winnerContext).toEqual({ selectedCpm: 1.25 }); + expect(service.recognize(reservationId(1))).toMatchObject({ + recognized: true, + state: 'renderable', + expiresAt: 1_000 + RENDER_RESERVATION_LIFETIME_MS, + }); + expect(service.recognize(reservationId(2))).toEqual({ + recognized: true, + state: 'unselected', + expiresAt: 10 + PREBID_ADMISSION_LEASE_MS, + }); + }); + + 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'] 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('transfers immutable context before consumption and preserves it after projection replacement', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + const source = admSource('
original winner
'); + 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(), + adoptWinnerContext(winnerContext: WinnerContext): boolean { + const recognition = service.recognize(reservationId()); + if (recognition.recognized) observedStates.push(recognition.state); + return attempt.adoptWinnerContext(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' }); + }); + + 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, + adoptWinnerContext(context: WinnerContext): boolean { + nested = service.claim({ + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attempt: sink, + pucSource: secondSource, + }); + acceptedContext = context; + 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('rolls back a throwing context transfer without retaining the attempted 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, + adoptWinnerContext(): boolean { + 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: 'renderable' }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 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 }); + }); +}); From cb991f3ca66ca48fc2c55a7deb53cbdc7184086b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:26:38 -0700 Subject: [PATCH 036/194] Harden renderer reservation failure handling --- .../lib/src/integrations/aps/render.ts | 12 +- .../lib/src/kernel/sessions.ts | 58 +- .../lib/src/services/reservations.ts | 387 +++++++++--- .../lib/test/integrations/aps/render.test.ts | 22 + .../lib/test/kernel/sessions.test.ts | 21 +- .../lib/test/services/reservations.test.ts | 592 +++++++++++++++++- 6 files changed, 965 insertions(+), 127 deletions(-) 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 cb75a3024..e01f4781d 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -2,6 +2,8 @@ import { log } from '../../core/log'; import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types'; import { validateApsRenderer } from '../../core/contracts/aps_renderer'; +const objectFreezeIntrinsic = Object.freeze; + export { parseApsRendererDescriptor, validateApsRenderer } from '../../core/contracts/aps_renderer'; export const APS_RENDERER_PATH = '/integrations/aps/renderer'; @@ -21,8 +23,14 @@ const MAX_PREBID_ID_BYTES = 1024; /** Validate, copy, and freeze one APS tagged render source. */ export function prepareApsRenderSource(input: unknown): Readonly | undefined { - const renderer = validateApsRenderer(input); - return renderer ? Object.freeze(renderer) : undefined; + try { + const renderer = validateApsRenderer(input); + return renderer + ? (Reflect.apply(objectFreezeIntrinsic, Object, [renderer]) as Readonly) + : undefined; + } catch { + return undefined; + } } function isRecord(value: unknown): value is Record { diff --git a/crates/trusted-server-js/lib/src/kernel/sessions.ts b/crates/trusted-server-js/lib/src/kernel/sessions.ts index 0bc66e758..fa67e3bd0 100644 --- a/crates/trusted-server-js/lib/src/kernel/sessions.ts +++ b/crates/trusted-server-js/lib/src/kernel/sessions.ts @@ -1,11 +1,23 @@ import { DisposableStack, type DisposeCallback, type DisposalErrorHandler } from './disposable'; import type { IdentityGenerationResult, NavigationIdentityIssuer } from './identity'; +const objectFreezeIntrinsic = Object.freeze; + +function freezeValue(value: Value): Readonly { + return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; +} + /** Immutable price authority transferred from winner admission into one attempt. */ export interface WinnerContext { readonly selectedCpm: number; } +/** Reversible admission of one exact winner context into an attempt. */ +export interface WinnerContextAdmission { + readonly commit: () => boolean; + readonly rollback: () => boolean; +} + /** Factory that obtains one fresh eight-byte identity prefix per navigation. */ export type NavigationIdentityIssuerFactory = () => IdentityGenerationResult; @@ -126,7 +138,7 @@ export interface RenderAttemptScope { callback: (...arguments_: Arguments) => unknown ) => (...arguments_: Arguments) => boolean; readonly isCurrent: () => boolean; - readonly adoptWinnerContext: (context: WinnerContext) => boolean; + readonly prepareWinnerContext: (context: WinnerContext) => WinnerContextAdmission | undefined; readonly onDispose: (kind: string, callback: DisposeCallback) => void; readonly dispose: () => void; } @@ -211,6 +223,7 @@ class OwnerScope { class RenderAttemptOwner implements RenderAttemptScope { private readonly scope: OwnerScope; private acceptedWinnerContext: WinnerContext | undefined; + private pendingWinnerAdmission: object | undefined; public constructor( public readonly id: string, @@ -238,9 +251,8 @@ class RenderAttemptOwner implements RenderAttemptScope { return this.acceptedWinnerContext; } - public adoptWinnerContext(context: WinnerContext): boolean { - if (!this.isCurrent()) return false; - if (this.acceptedWinnerContext !== undefined) return this.acceptedWinnerContext === context; + public prepareWinnerContext(context: WinnerContext): WinnerContextAdmission | undefined { + if (!this.isCurrent() || this.pendingWinnerAdmission !== undefined) return undefined; try { const descriptor = Object.getOwnPropertyDescriptor(context, 'selectedCpm'); if ( @@ -255,12 +267,41 @@ class RenderAttemptOwner implements RenderAttemptScope { !Number.isFinite(descriptor.value) || descriptor.value < 0 ) { - return false; + return undefined; } - this.acceptedWinnerContext = context; - return true; + const previous = this.acceptedWinnerContext; + if (previous !== undefined && previous !== context) return undefined; + const token = freezeValue({}); + this.pendingWinnerAdmission = token; + let committed = false; + return freezeValue({ + commit: (): boolean => { + if (committed) return this.acceptedWinnerContext === context; + if ( + this.pendingWinnerAdmission !== token || + !this.isCurrent() || + this.acceptedWinnerContext !== previous + ) { + return false; + } + this.acceptedWinnerContext = context; + this.pendingWinnerAdmission = undefined; + committed = true; + return true; + }, + rollback: (): boolean => { + if (this.pendingWinnerAdmission === token) this.pendingWinnerAdmission = undefined; + if (committed && previous === undefined && this.acceptedWinnerContext === context) { + this.acceptedWinnerContext = undefined; + committed = false; + return true; + } + committed = false; + return this.acceptedWinnerContext === previous; + }, + }); } catch { - return false; + return undefined; } } @@ -283,6 +324,7 @@ class RenderAttemptOwner implements RenderAttemptScope { } public dispose(): void { + this.pendingWinnerAdmission = undefined; this.scope.dispose(); } } diff --git a/crates/trusted-server-js/lib/src/services/reservations.ts b/crates/trusted-server-js/lib/src/services/reservations.ts index e885e1994..31c566e85 100644 --- a/crates/trusted-server-js/lib/src/services/reservations.ts +++ b/crates/trusted-server-js/lib/src/services/reservations.ts @@ -1,4 +1,4 @@ -import type { WinnerContext } from '../kernel/sessions'; +import type { WinnerContext, WinnerContextAdmission } from '../kernel/sessions'; export const RENDER_RESERVATION_LIFETIME_MS = 15 * 60 * 1_000; export const PREBID_ADMISSION_LEASE_MS = 10_000; @@ -9,6 +9,10 @@ const AUCTION_ID = /^[A-Za-z0-9._:-]{1,128}$/; const MAX_RESERVATIONS = 320; const textEncoder = new TextEncoder(); +const objectFreezeIntrinsic = Object.freeze; +const regexpTestIntrinsic = RegExp.prototype.test; +const stringCharCodeAtIntrinsic = String.prototype.charCodeAt; +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; const mapDeleteIntrinsic = Map.prototype.delete; const mapEntriesIntrinsic = Map.prototype.entries; const mapGetIntrinsic = Map.prototype.get; @@ -23,6 +27,8 @@ const mapEntryIteratorNextIntrinsic = Object.getPrototypeOf(new Map().entries()) const mapSizeGetter = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.get as ( this: Map ) => number; +const weakMapGetIntrinsic = WeakMap.prototype.get; +const weakMapSetIntrinsic = WeakMap.prototype.set; const performanceNowIntrinsic = performance.now; function mapValue(map: Map, key: Key): Value | undefined { @@ -41,6 +47,21 @@ function mapSize(map: Map): number { return Reflect.apply(mapSizeGetter, map, []) as number; } +function weakMapValue( + map: WeakMap, + key: Key +): Value | undefined { + return Reflect.apply(weakMapGetIntrinsic, map, [key]) as Value | undefined; +} + +function setWeakMapValue( + map: WeakMap, + key: Key, + value: Value +): void { + Reflect.apply(weakMapSetIntrinsic, map, [key, value]); +} + function mapValueSnapshot(map: Map): Value[] { const iterator = Reflect.apply(mapValuesIntrinsic, map, []) as IterableIterator; const values: Value[] = []; @@ -99,21 +120,28 @@ function ownDataRecord( } function frozenResult(value: Value): Readonly { - return Object.freeze(value); + return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; +} + +function matches(pattern: RegExp, value: string): boolean { + return Reflect.apply(regexpTestIntrinsic, pattern, [value]) as boolean; } function validBoundedString(value: unknown, maximumBytes: number): value is string { if (typeof value !== 'string' || value.length === 0) return false; for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); + const code = Reflect.apply(stringCharCodeAtIntrinsic, value, [index]) as number; if (code <= 0x1f || code === 0x7f) return false; if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(index + 1); + const next = Reflect.apply(stringCharCodeAtIntrinsic, value, [index + 1]) as number; if (!(next >= 0xdc00 && next <= 0xdfff)) return false; index += 1; } else if (code >= 0xdc00 && code <= 0xdfff) return false; } - return textEncoder.encode(value).length <= maximumBytes; + return ( + (Reflect.apply(textEncoderEncodeIntrinsic, textEncoder, [value]) as Uint8Array).length <= + maximumBytes + ); } function copyTaggedRenderSource(value: unknown): ReservationRenderSource | undefined { @@ -144,7 +172,7 @@ function copyTaggedRenderSource(value: unknown): ReservationRenderSource | undef ) { return undefined; } - return Object.freeze(output) as ReservationRenderSource; + return frozenResult(output) as ReservationRenderSource; } catch { return undefined; } @@ -161,7 +189,7 @@ export interface ReservationAttempt { readonly slot: string; readonly winnerContext: WinnerContext | undefined; readonly isCurrent: () => boolean; - readonly adoptWinnerContext: (context: WinnerContext) => boolean; + readonly prepareWinnerContext: (context: WinnerContext) => WinnerContextAdmission | undefined; } export interface ReservationServiceOptions { @@ -278,6 +306,7 @@ export type ReservationClaimResult = }>; export interface ReservationServiceInventory { + readonly clockFaulted: boolean; readonly disposed: boolean; readonly size: number; readonly live: number; @@ -316,7 +345,6 @@ interface LiveReservation { readonly navigationGeneration: object; readonly renderSource: ReservationRenderSource; readonly winnerContext: WinnerContext; - readonly ownerToken: object; expiresAt: number; state: 'awaiting_prebid_selection' | 'renderable'; attemptId: string | undefined; @@ -334,12 +362,20 @@ interface ReservationTombstone { type ReservationEntry = LiveReservation | ReservationTombstone; interface OwnerSnapshot { + readonly identity: object; readonly generation: object; readonly isCurrent: () => boolean; readonly onDispose: (kind: string, callback: () => void) => void; readonly readGeneration: () => object | undefined; } +interface OwnerRegistration { + readonly identity: object; + readonly token: object; + disposed: boolean; + ready: boolean; +} + function liveEntry(entry: ReservationEntry): entry is LiveReservation { return entry.state === 'awaiting_prebid_selection' || entry.state === 'renderable'; } @@ -348,6 +384,22 @@ function ownerDisposalState(entry: LiveReservation): 'aborted' | 'disposed' { return entry.state === 'awaiting_prebid_selection' ? 'aborted' : 'disposed'; } +function validRenderTombstoneState(value: unknown): value is 'disposed' | 'stale' { + return value === 'disposed' || value === 'stale'; +} + +function validPrebidLeaseTombstoneState( + value: unknown +): value is 'prebid_admission_failed' | 'prebid_contract_violation' { + return value === 'prebid_admission_failed' || value === 'prebid_contract_violation'; +} + +function validPrebidGroupTombstoneState( + value: unknown +): value is 'aborted' | 'prebid_selection_timeout' { + return value === 'aborted' || value === 'prebid_selection_timeout'; +} + function winnerContext(value: unknown): WinnerContext | undefined { const record = ownDataRecord(value, ['selectedCpm']); if ( @@ -397,6 +449,7 @@ function ownerSnapshot(value: unknown): OwnerSnapshot | undefined { return undefined; } return { + identity: value as object, generation, isCurrent: () => Reflect.apply(isCurrentMethod, value, []) as boolean, onDispose: (kind, callback) => { @@ -438,21 +491,66 @@ function attemptIdentity(attempt: ReservationAttempt): { id: string; slot: strin try { const id = attempt.id; const slot = attempt.slot; - if (!ATTEMPT_ID.test(id) || !validBoundedString(slot, 256)) return undefined; + if (!matches(ATTEMPT_ID, id) || !validBoundedString(slot, 256)) return undefined; return { id, slot }; } catch { return undefined; } } +function prepareWinnerAdmission( + attempt: ReservationAttempt, + context: WinnerContext +): WinnerContextAdmission | undefined { + try { + const prepare = attempt.prepareWinnerContext; + if (typeof prepare !== 'function') return undefined; + const admission = Reflect.apply(prepare, attempt, [context]) as unknown; + if ((typeof admission !== 'object' && typeof admission !== 'function') || admission === null) { + return undefined; + } + const commit = (admission as WinnerContextAdmission).commit; + const rollback = (admission as WinnerContextAdmission).rollback; + if (typeof commit !== 'function' || typeof rollback !== 'function') return undefined; + return frozenResult({ + commit: (): boolean => Reflect.apply(commit, admission, []) === true, + rollback: (): boolean => Reflect.apply(rollback, admission, []) === true, + }); + } catch { + return undefined; + } +} + +function commitWinnerAdmission( + attempt: ReservationAttempt, + admission: WinnerContextAdmission, + context: WinnerContext +): boolean { + try { + return admission.commit() === true && attempt.winnerContext === context; + } catch { + return false; + } +} + +function rollbackWinnerAdmission(admission: WinnerContextAdmission | undefined): void { + try { + admission?.rollback(); + } catch { + // The reservation is terminally suppressed even when a hostile rollback fails. + } +} + function monotonicClock(source: () => number): () => number | undefined { let last = Number.NEGATIVE_INFINITY; return (): number | undefined => { try { const value = source(); - if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined; - last = Math.max(last, value); - return last; + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value < last) { + return undefined; + } + last = value; + return value; } catch { return undefined; } @@ -470,7 +568,7 @@ function defaultNow(): number { /** Whether a candidate is one exact server-minted renderer reservation id. */ export function isRendererReservationId(value: unknown): value is string { - return typeof value === 'string' && RESERVATION_ID.test(value); + return typeof value === 'string' && matches(RESERVATION_ID, value); } /** Construct the runtime-owned renderer reservation service. */ @@ -478,6 +576,8 @@ export function createReservationService(options: ReservationServiceOptions): Re let nowSource: () => number = defaultNow; let prepareRenderSource: ReservationServiceOptions['prepareRenderSource'] | undefined; let disposed = false; + let clockFaulted = false; + let storeFaulted = false; try { if (options.now !== undefined) { if (typeof options.now !== 'function') disposed = true; @@ -490,6 +590,7 @@ export function createReservationService(options: ReservationServiceOptions): Re } const readNow = monotonicClock(nowSource); const entries = new Map(); + const ownerRegistrations = new WeakMap(); const disposeStore = (): void => { disposed = true; @@ -514,23 +615,122 @@ export function createReservationService(options: ReservationServiceOptions): Re }; const clock = (): number | undefined => { - if (disposed) return undefined; + if (disposed || clockFaulted || storeFaulted) return undefined; const now = readNow(); if (now === undefined) { - disposeStore(); + if (mapSize(entries) === 0) disposeStore(); + else clockFaulted = true; return undefined; } prune(now); return now; }; + const refreshForLookup = (): boolean => { + if (disposed) return false; + if (clockFaulted || storeFaulted) return true; + const now = clock(); + return now !== undefined || clockFaulted || storeFaulted; + }; + + const publishEntry = ( + reservationId: string, + expected: ReservationEntry | undefined, + next: ReservationEntry + ): boolean => { + if (disposed || clockFaulted || storeFaulted) return false; + let before: ReservationEntry | undefined; + try { + before = mapValue(entries, reservationId); + } catch { + storeFaulted = true; + return false; + } + if (before !== expected) return false; + try { + setMapValue(entries, reservationId, next); + } catch { + // The captured operation may have applied the exact value before throwing. + } + let after: ReservationEntry | undefined; + try { + after = mapValue(entries, reservationId); + } catch { + storeFaulted = true; + return false; + } + if (after === next) return true; + storeFaulted = true; + return false; + }; + const replaceWithTombstone = ( reservationId: string, expected: LiveReservation, state: ReservationTombstoneState ): boolean => { - if (mapValue(entries, reservationId) !== expected) return false; - setMapValue(entries, reservationId, frozenResult({ expiresAt: expected.expiresAt, state })); + const tombstone = frozenResult({ expiresAt: expected.expiresAt, state }); + return publishEntry(reservationId, expected, tombstone); + }; + + const disposeOwnerEntries = (generation: object, token: object): void => { + const registration = weakMapValue(ownerRegistrations, generation); + if (!registration || registration.token !== token) return; + registration.disposed = true; + const snapshot = entrySnapshot(entries); + for (let index = 0; index < snapshot.length; index += 1) { + const pair = snapshot[index]; + if (!pair) continue; + const entry = pair[1]; + if (liveEntry(entry) && entry.navigationGeneration === generation) { + replaceWithTombstone(pair[0], entry, ownerDisposalState(entry)); + } + } + }; + + const ensureOwnerRegistration = (owner: OwnerSnapshot): boolean => { + const existing = weakMapValue(ownerRegistrations, owner.generation); + if (existing?.identity === owner.identity) { + const ownerIsCurrent = currentOwner(owner); + const currentGeneration = owner.readGeneration(); + return ( + ownerIsCurrent && + currentGeneration === owner.generation && + weakMapValue(ownerRegistrations, owner.generation) === existing && + existing.ready && + !existing.disposed + ); + } + + const registration: OwnerRegistration = { + identity: owner.identity, + token: frozenResult({}), + disposed: false, + ready: false, + }; + setWeakMapValue(ownerRegistrations, owner.generation, registration); + const generation = owner.generation; + const token = registration.token; + try { + owner.onDispose('reservation', () => disposeOwnerEntries(generation, token)); + } catch { + if (weakMapValue(ownerRegistrations, generation) === registration) { + registration.disposed = true; + } + return false; + } + const ownerIsCurrent = currentOwner(owner); + const currentGeneration = owner.readGeneration(); + if ( + !ownerIsCurrent || + currentGeneration !== generation || + weakMapValue(ownerRegistrations, generation) !== registration || + registration.disposed + ) { + registration.disposed = true; + return false; + } + registration.ready = true; return true; }; @@ -574,7 +774,7 @@ export function createReservationService(options: ReservationServiceOptions): Re if (prebid) { if ( typeof input.auctionId !== 'string' || - !AUCTION_ID.test(input.auctionId) || + !matches(AUCTION_ID, input.auctionId) || !validBoundedString(input.adUnitCode, 256) || input.adUnitCode !== input.slot || !prebidCpmMatches(input.prebidBid, context) @@ -584,7 +784,7 @@ export function createReservationService(options: ReservationServiceOptions): Re auctionId = input.auctionId; adUnitCode = input.adUnitCode; } else { - if (typeof input.attemptId !== 'string' || !ATTEMPT_ID.test(input.attemptId)) { + if (typeof input.attemptId !== 'string' || !matches(ATTEMPT_ID, input.attemptId)) { return failure('invalid_attempt'); } attemptId = input.attemptId; @@ -610,7 +810,6 @@ export function createReservationService(options: ReservationServiceOptions): Re navigationGeneration: owner.generation, renderSource, winnerContext: context, - ownerToken: Object.freeze({}), expiresAt, state: prebid ? 'awaiting_prebid_selection' : 'renderable', attemptId, @@ -619,33 +818,28 @@ export function createReservationService(options: ReservationServiceOptions): Re busy: false, pucSource: undefined, }; - setMapValue(entries, input.reservationId, entry); - const publishedReservationId = input.reservationId; - const publishedOwnerToken = entry.ownerToken; - try { - owner.onDispose('reservation', () => { - const current = mapValue(entries, publishedReservationId); - if (current && liveEntry(current) && current.ownerToken === publishedOwnerToken) { - replaceWithTombstone(publishedReservationId, current, ownerDisposalState(current)); - } - }); - if ( - mapValue(entries, input.reservationId) !== entry || - !currentOwner(owner) || - owner.readGeneration() !== entry.navigationGeneration - ) { - replaceWithTombstone(input.reservationId, entry, ownerDisposalState(entry)); - return failure('stale_owner'); - } - } catch { + const success = frozenResult({ ok: true as const, expiresAt: entry.expiresAt }); + const staleOwner = failure('stale_owner'); + if (!publishEntry(input.reservationId, undefined, entry)) { + return failure('service_disposed'); + } + const ownerRegistered = ensureOwnerRegistration(owner); + const ownerIsCurrent = currentOwner(owner); + const currentGeneration = owner.readGeneration(); + if ( + !ownerRegistered || + !ownerIsCurrent || + currentGeneration !== entry.navigationGeneration || + mapValue(entries, input.reservationId) !== entry + ) { replaceWithTombstone(input.reservationId, entry, ownerDisposalState(entry)); - return failure('stale_owner'); + return staleOwner; } - return frozenResult({ ok: true, expiresAt: entry.expiresAt }); + return success; }; const recognize = (reservationId: unknown): ReservationRecognition => { - if (clock() === undefined || typeof reservationId !== 'string') { + if (!refreshForLookup() || typeof reservationId !== 'string') { return frozenResult({ recognized: false }); } const entry = mapValue(entries, reservationId); @@ -698,40 +892,59 @@ export function createReservationService(options: ReservationServiceOptions): Re return failure('invalid_attempt'); } entry.busy = true; - let adopted: boolean; - try { - adopted = attempt.adoptWinnerContext(entry.winnerContext) === true; - } catch { - adopted = false; - } - if ( - !adopted || - mapValue(entries, fields.reservationId) !== entry || - !currentAttempt(attempt) - ) { - if (mapValue(entries, fields.reservationId) === entry) entry.busy = false; + const admission = prepareWinnerAdmission(attempt, entry.winnerContext); + const committed = admission + ? commitWinnerAdmission(attempt, admission, entry.winnerContext) + : false; + const attemptIsCurrent = currentAttempt(attempt); + if (!committed || !attemptIsCurrent || mapValue(entries, fields.reservationId) !== entry) { + rollbackWinnerAdmission(admission); + replaceWithTombstone(fields.reservationId, entry, 'stale'); return failure('invalid_attempt'); } - entry.attemptId = identity.id; - entry.state = 'renderable'; - entry.expiresAt = promotedExpiry; - entry.busy = false; + const promoted: LiveReservation = { + reservationId: entry.reservationId, + slot: entry.slot, + navigationGeneration: entry.navigationGeneration, + renderSource: entry.renderSource, + winnerContext: entry.winnerContext, + expiresAt: promotedExpiry, + state: 'renderable', + attemptId: identity.id, + auctionId: entry.auctionId, + adUnitCode: entry.adUnitCode, + busy: true, + pucSource: undefined, + }; + const success = frozenResult({ ok: true as const, expiresAt: promotedExpiry }); + if (!publishEntry(fields.reservationId, entry, promoted)) { + rollbackWinnerAdmission(admission); + replaceWithTombstone(fields.reservationId, entry, 'stale'); + return failure('service_disposed'); + } + let losersSuppressed = true; const candidates = mapValueSnapshot(entries); for (let index = 0; index < candidates.length; index += 1) { const candidate = candidates[index]; if ( candidate && - candidate !== entry && + candidate !== promoted && liveEntry(candidate) && candidate.state === 'awaiting_prebid_selection' && - candidate.auctionId === entry.auctionId && - candidate.adUnitCode === entry.adUnitCode && - candidate.navigationGeneration === entry.navigationGeneration + candidate.auctionId === promoted.auctionId && + candidate.adUnitCode === promoted.adUnitCode && + candidate.navigationGeneration === promoted.navigationGeneration && + !replaceWithTombstone(candidate.reservationId, candidate, 'unselected') ) { - replaceWithTombstone(candidate.reservationId, candidate, 'unselected'); + losersSuppressed = false; } } - return frozenResult({ ok: true, expiresAt: entry.expiresAt }); + if (!losersSuppressed || storeFaulted) { + rollbackWinnerAdmission(admission); + return failure('service_disposed'); + } + promoted.busy = false; + return success; }, claim(input): ReservationClaimResult { const minimalId = (() => { @@ -741,7 +954,7 @@ export function createReservationService(options: ReservationServiceOptions): Re return undefined; } })(); - if (clock() === undefined || typeof minimalId !== 'string') { + if (!refreshForLookup() || typeof minimalId !== 'string') { return frozenResult({ recognized: false }); } const entry = mapValue(entries, minimalId); @@ -749,6 +962,9 @@ export function createReservationService(options: ReservationServiceOptions): Re if (!liveEntry(entry)) { return refusedClaim(entry.state); } + if (clockFaulted || storeFaulted) { + return refusedClaim(entry.state); + } if (entry.busy) { return refusedClaim(entry.state); } @@ -780,36 +996,36 @@ export function createReservationService(options: ReservationServiceOptions): Re ) { return refusedClaim(entry.state); } - entry.busy = true; - entry.pucSource = fields.pucSource; - let adopted: boolean; - try { - adopted = attempt.adoptWinnerContext(entry.winnerContext) === true; - } catch { - adopted = false; - } - if (!adopted || mapValue(entries, minimalId) !== entry || !currentAttempt(attempt)) { - if (mapValue(entries, minimalId) === entry) { - entry.pucSource = undefined; - entry.busy = false; - return refusedClaim(entry.state); - } - const replacement = mapValue(entries, minimalId); - return refusedClaim(replacement?.state ?? 'stale'); - } const result = frozenResult({ recognized: true as const, claimed: true as const, renderSource: entry.renderSource, winnerContext: entry.winnerContext, - pucSource: entry.pucSource, + pucSource: fields.pucSource, expiresAt: entry.expiresAt, }); - replaceWithTombstone(minimalId, entry, 'consumed'); + entry.busy = true; + const admission = prepareWinnerAdmission(attempt, entry.winnerContext); + const committed = admission + ? commitWinnerAdmission(attempt, admission, entry.winnerContext) + : false; + const attemptIsCurrent = currentAttempt(attempt); + if (!committed || !attemptIsCurrent || mapValue(entries, minimalId) !== entry) { + rollbackWinnerAdmission(admission); + replaceWithTombstone(minimalId, entry, 'stale'); + const replacement = mapValue(entries, minimalId); + return refusedClaim(replacement?.state ?? 'stale'); + } + if (!replaceWithTombstone(minimalId, entry, 'consumed')) { + rollbackWinnerAdmission(admission); + const replacement = mapValue(entries, minimalId); + return refusedClaim(replacement?.state ?? 'stale'); + } return result; }, recognize, tombstone(input, state): boolean { + if (!validRenderTombstoneState(state)) return false; const fields = ownDataRecord(input, [ 'reservationId', 'slot', @@ -833,6 +1049,7 @@ export function createReservationService(options: ReservationServiceOptions): Re return replaceWithTombstone(fields.reservationId, entry, state); }, tombstonePrebidLease(input, state): boolean { + if (!validPrebidLeaseTombstoneState(state)) return false; const fields = ownDataRecord(input, [ 'reservationId', 'auctionId', @@ -857,6 +1074,7 @@ export function createReservationService(options: ReservationServiceOptions): Re return replaceWithTombstone(fields.reservationId, entry, state); }, tombstonePrebidGroup(input, state): number { + if (!validPrebidGroupTombstoneState(state)) return 0; const fields = ownDataRecord(input, ['auctionId', 'adUnitCode', 'navigationGeneration']); if (clock() === undefined || !fields) { return 0; @@ -901,6 +1119,7 @@ export function createReservationService(options: ReservationServiceOptions): Re } else tombstones += 1; } return frozenResult({ + clockFaulted, disposed, size: mapSize(entries), live, 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 e8ba126be..b3af7eb51 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 @@ -283,6 +283,28 @@ describe('APS renderer validation', () => { 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); diff --git a/crates/trusted-server-js/lib/test/kernel/sessions.test.ts b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts index c39829679..633510963 100644 --- a/crates/trusted-server-js/lib/test/kernel/sessions.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts @@ -321,7 +321,7 @@ describe('runtime and navigation sessions', () => { }); }); - it('adopts one immutable winner context and rejects replacement or stale adoption', () => { + 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'); @@ -332,13 +332,24 @@ describe('runtime and navigation sessions', () => { const accepted = Object.freeze({ selectedCpm: 1.25 }); expect(attempt.value.winnerContext).toBeUndefined(); - expect(attempt.value.adoptWinnerContext(accepted)).toBe(true); + 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.adoptWinnerContext(accepted)).toBe(true); - expect(attempt.value.adoptWinnerContext(Object.freeze({ selectedCpm: 1.25 }))).toBe(false); + expect(attempt.value.prepareWinnerContext(accepted)?.commit()).toBe(true); + expect( + attempt.value.prepareWinnerContext(Object.freeze({ selectedCpm: 1.25 })) + ).toBeUndefined(); attempt.value.dispose(); - expect(attempt.value.adoptWinnerContext(Object.freeze({ selectedCpm: 2 }))).toBe(false); + expect(attempt.value.prepareWinnerContext(Object.freeze({ selectedCpm: 2 }))).toBeUndefined(); expect(attempt.value.winnerContext).toBe(accepted); }); diff --git a/crates/trusted-server-js/lib/test/services/reservations.test.ts b/crates/trusted-server-js/lib/test/services/reservations.test.ts index 3da985f48..9c1bd785c 100644 --- a/crates/trusted-server-js/lib/test/services/reservations.test.ts +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -15,6 +15,7 @@ import { createReservationService, isRendererReservationId, type ReservationOwner, + type ReservationRenderSource, } from '../../src/services/reservations'; const CACHE_ID = '123e4567-e89b-42d3-a456-426614174000'; @@ -301,9 +302,17 @@ describe('renderer reservation identity and registration', () => { return adopted; }, isCurrent: () => true, - adoptWinnerContext: (context) => { - adopted = context; - return true; + prepareWinnerContext: (context) => { + return { + commit: () => { + adopted = context; + return true; + }, + rollback: () => { + if (adopted === context) adopted = undefined; + return true; + }, + }; }, }, pucSource: Object.freeze({}), @@ -316,6 +325,220 @@ describe('renderer reservation identity and registration', () => { } }); + 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).toBeUndefined(); + } + }); + + 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({}); @@ -396,21 +619,56 @@ describe('fixed expiry, capacity, and tombstones', () => { } }); - it('never moves the monotonic clock backward', () => { - let now = 100; + 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 attempt = renderAttempt(navigation); - const service = serviceAt(() => now); - expect(registerRender(service, navigation, attempt)).toEqual({ + 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, - expiresAt: 100 + RENDER_RESERVATION_LIFETIME_MS, }); + expect(registerRender(service, navigation, tombstonedAttempt, reservationId(1))).toMatchObject({ + ok: true, + }); + expect(tombstone(service, navigation, tombstonedAttempt, reservationId(1), 'disposed')).toBe( + true + ); - now = 1; - expect(service.recognize(reservationId())).toEqual({ + 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', - expiresAt: 100 + RENDER_RESERVATION_LIFETIME_MS, + }); + 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, }); }); @@ -565,6 +823,72 @@ describe('fixed expiry, capacity, and tombstones', () => { 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(); @@ -616,6 +940,57 @@ describe('fixed expiry, capacity, and tombstones', () => { 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', () => { @@ -1115,10 +1490,10 @@ describe('atomic claims and disposal', () => { return attempt.winnerContext; }, isCurrent: () => attempt.isCurrent(), - adoptWinnerContext(winnerContext: WinnerContext): boolean { + prepareWinnerContext(winnerContext: WinnerContext) { const recognition = service.recognize(reservationId()); if (recognition.recognized) observedStates.push(recognition.state); - return attempt.adoptWinnerContext(winnerContext); + return attempt.prepareWinnerContext(winnerContext); }, }; @@ -1153,16 +1528,24 @@ describe('atomic claims and disposal', () => { return acceptedContext; }, isCurrent: () => true, - adoptWinnerContext(context: WinnerContext): boolean { - nested = service.claim({ - reservationId: reservationId(), - slot: attempt.slot, - navigationGeneration: navigation.generation, - attempt: sink, - pucSource: secondSource, - }); - acceptedContext = context; - return 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; + }, + }; }, }; @@ -1183,7 +1566,7 @@ describe('atomic claims and disposal', () => { }); }); - it('rolls back a throwing context transfer without retaining the attempted PUC source', () => { + it('terminally suppresses a throwing context preparation without retaining PUC source', () => { const { navigation } = runtimeNavigation(); const attempt = renderAttempt(navigation); const service = serviceAt(() => 0); @@ -1193,7 +1576,7 @@ describe('atomic claims and disposal', () => { slot: attempt.slot, winnerContext: undefined, isCurrent: () => true, - adoptWinnerContext(): boolean { + prepareWinnerContext() { throw new Error('partial transfer failed'); }, }; @@ -1206,9 +1589,162 @@ describe('atomic claims and disposal', () => { attempt: throwingSink, pucSource: Object.freeze({}), }) - ).toEqual({ recognized: true, claimed: false, state: 'renderable' }); + ).toEqual({ recognized: true, claimed: false, state: 'stale' }); expect(service.snapshotInventoryForTest()).toMatchObject({ - live: 1, + 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, }); }); From dbf11bdf1ec806213cded979aaba138a15ac6588 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:39:18 -0700 Subject: [PATCH 037/194] Harden reservation owner publication and cleanup --- .../lib/src/services/reservations.ts | 142 ++++++++--- .../lib/test/services/reservations.test.ts | 234 ++++++++++++++++++ 2 files changed, 340 insertions(+), 36 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/reservations.ts b/crates/trusted-server-js/lib/src/services/reservations.ts index 31c566e85..c9171ba71 100644 --- a/crates/trusted-server-js/lib/src/services/reservations.ts +++ b/crates/trusted-server-js/lib/src/services/reservations.ts @@ -370,12 +370,16 @@ interface OwnerSnapshot { } interface OwnerRegistration { + readonly callbackState: OwnerCallbackState; readonly identity: object; - readonly token: object; - disposed: boolean; ready: boolean; } +interface OwnerCallbackState { + active: boolean; + disposed: boolean; +} + function liveEntry(entry: ReservationEntry): entry is LiveReservation { return entry.state === 'awaiting_prebid_selection' || entry.state === 'renderable'; } @@ -633,12 +637,20 @@ export function createReservationService(options: ReservationServiceOptions): Re return now !== undefined || clockFaulted || storeFaulted; }; + const refreshForTerminalMutation = (): boolean => { + if (disposed || storeFaulted) return false; + if (clockFaulted) return true; + const now = clock(); + return now !== undefined || clockFaulted; + }; + const publishEntry = ( reservationId: string, expected: ReservationEntry | undefined, - next: ReservationEntry + next: ReservationEntry, + allowClockFault = false ): boolean => { - if (disposed || clockFaulted || storeFaulted) return false; + if (disposed || storeFaulted || (clockFaulted && !allowClockFault)) return false; let before: ReservationEntry | undefined; try { before = mapValue(entries, reservationId); @@ -670,13 +682,41 @@ export function createReservationService(options: ReservationServiceOptions): Re state: ReservationTombstoneState ): boolean => { const tombstone = frozenResult({ expiresAt: expected.expiresAt, state }); - return publishEntry(reservationId, expected, tombstone); + return publishEntry(reservationId, expected, tombstone, true); }; - const disposeOwnerEntries = (generation: object, token: object): void => { - const registration = weakMapValue(ownerRegistrations, generation); - if (!registration || registration.token !== token) return; - registration.disposed = true; + const readOwnerRegistration = ( + generation: object + ): Readonly<{ ok: true; value: OwnerRegistration | undefined }> | Readonly<{ ok: false }> => { + try { + return { ok: true, value: weakMapValue(ownerRegistrations, generation) }; + } catch { + return { ok: false }; + } + }; + + const publishOwnerRegistration = ( + generation: object, + existing: OwnerRegistration | undefined, + registration: OwnerRegistration + ): boolean => { + try { + setWeakMapValue(ownerRegistrations, generation, registration); + } catch { + // The captured operation may have applied the exact registration before throwing. + } + const current = readOwnerRegistration(generation); + if (!current.ok || current.value !== registration) { + registration.callbackState.active = false; + return false; + } + if (existing) existing.callbackState.active = false; + return true; + }; + + const disposeOwnerEntries = (generation: object, state: OwnerCallbackState): void => { + if (!state.active || state.disposed) return; + state.disposed = true; const snapshot = entrySnapshot(entries); for (let index = 0; index < snapshot.length; index += 1) { const pair = snapshot[index]; @@ -688,50 +728,69 @@ export function createReservationService(options: ReservationServiceOptions): Re } }; - const ensureOwnerRegistration = (owner: OwnerSnapshot): boolean => { - const existing = weakMapValue(ownerRegistrations, owner.generation); + const ensureOwnerRegistration = (owner: OwnerSnapshot): OwnerRegistration | undefined => { + const initial = readOwnerRegistration(owner.generation); + if (!initial.ok) return undefined; + const existing = initial.value; if (existing?.identity === owner.identity) { const ownerIsCurrent = currentOwner(owner); const currentGeneration = owner.readGeneration(); - return ( - ownerIsCurrent && + const current = readOwnerRegistration(owner.generation); + return ownerIsCurrent && currentGeneration === owner.generation && - weakMapValue(ownerRegistrations, owner.generation) === existing && + current.ok && + current.value === existing && existing.ready && - !existing.disposed - ); + existing.callbackState.active && + !existing.callbackState.disposed + ? existing + : undefined; } + const callbackState: OwnerCallbackState = { + active: true, + disposed: false, + }; const registration: OwnerRegistration = { + callbackState, identity: owner.identity, - token: frozenResult({}), - disposed: false, ready: false, }; - setWeakMapValue(ownerRegistrations, owner.generation, registration); const generation = owner.generation; - const token = registration.token; + if (!publishOwnerRegistration(generation, existing, registration)) return undefined; try { - owner.onDispose('reservation', () => disposeOwnerEntries(generation, token)); + owner.onDispose('reservation', () => disposeOwnerEntries(generation, callbackState)); } catch { - if (weakMapValue(ownerRegistrations, generation) === registration) { - registration.disposed = true; - } - return false; + callbackState.disposed = true; + return undefined; } const ownerIsCurrent = currentOwner(owner); const currentGeneration = owner.readGeneration(); + const current = readOwnerRegistration(generation); if ( !ownerIsCurrent || currentGeneration !== generation || - weakMapValue(ownerRegistrations, generation) !== registration || - registration.disposed + !current.ok || + current.value !== registration || + !callbackState.active || + callbackState.disposed ) { - registration.disposed = true; - return false; + callbackState.disposed = true; + return undefined; } registration.ready = true; - return true; + return registration; + }; + + const currentOwnerRegistration = (owner: OwnerSnapshot, expected: OwnerRegistration): boolean => { + const current = readOwnerRegistration(owner.generation); + return ( + current.ok && + current.value === expected && + expected.ready && + expected.callbackState.active && + !expected.callbackState.disposed + ); }; const failure = ( @@ -820,17 +879,28 @@ export function createReservationService(options: ReservationServiceOptions): Re }; const success = frozenResult({ ok: true as const, expiresAt: entry.expiresAt }); const staleOwner = failure('stale_owner'); + const ownerRegistration = ensureOwnerRegistration(owner); + if (!ownerRegistration) { + const rejected = frozenResult({ + expiresAt: entry.expiresAt, + state: ownerDisposalState(entry), + }); + return publishEntry(input.reservationId, undefined, rejected) + ? staleOwner + : failure('service_disposed'); + } if (!publishEntry(input.reservationId, undefined, entry)) { return failure('service_disposed'); } - const ownerRegistered = ensureOwnerRegistration(owner); const ownerIsCurrent = currentOwner(owner); const currentGeneration = owner.readGeneration(); + const ownerRegistrationIsCurrent = currentOwnerRegistration(owner, ownerRegistration); + const entryIsCurrent = mapValue(entries, input.reservationId) === entry; if ( - !ownerRegistered || !ownerIsCurrent || currentGeneration !== entry.navigationGeneration || - mapValue(entries, input.reservationId) !== entry + !ownerRegistrationIsCurrent || + !entryIsCurrent ) { replaceWithTombstone(input.reservationId, entry, ownerDisposalState(entry)); return staleOwner; @@ -1032,7 +1102,7 @@ export function createReservationService(options: ReservationServiceOptions): Re 'navigationGeneration', 'attemptId', ]); - if (clock() === undefined || !fields || typeof fields.reservationId !== 'string') { + if (!refreshForTerminalMutation() || !fields || typeof fields.reservationId !== 'string') { return false; } const entry = mapValue(entries, fields.reservationId); @@ -1056,7 +1126,7 @@ export function createReservationService(options: ReservationServiceOptions): Re 'adUnitCode', 'navigationGeneration', ]); - if (clock() === undefined || !fields || typeof fields.reservationId !== 'string') { + if (!refreshForTerminalMutation() || !fields || typeof fields.reservationId !== 'string') { return false; } const entry = mapValue(entries, fields.reservationId); @@ -1076,7 +1146,7 @@ export function createReservationService(options: ReservationServiceOptions): Re tombstonePrebidGroup(input, state): number { if (!validPrebidGroupTombstoneState(state)) return 0; const fields = ownDataRecord(input, ['auctionId', 'adUnitCode', 'navigationGeneration']); - if (clock() === undefined || !fields) { + if (!refreshForTerminalMutation() || !fields) { return 0; } let count = 0; diff --git a/crates/trusted-server-js/lib/test/services/reservations.test.ts b/crates/trusted-server-js/lib/test/services/reservations.test.ts index 9c1bd785c..56c932524 100644 --- a/crates/trusted-server-js/lib/test/services/reservations.test.ts +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -505,8 +505,124 @@ describe('renderer reservation identity and registration', () => { } 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', () => { @@ -672,6 +788,124 @@ describe('fixed expiry, capacity, and tombstones', () => { }); }); + 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], From 3ba7fde4bdd8bff2dcf6feae276eb2c0f536be11 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:45:56 -0700 Subject: [PATCH 038/194] Bind reservation owners to fresh generations --- .../lib/src/services/reservations.ts | 7 +- .../lib/test/services/reservations.test.ts | 70 ++++++++++++++++++- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/reservations.ts b/crates/trusted-server-js/lib/src/services/reservations.ts index c9171ba71..828c9639f 100644 --- a/crates/trusted-server-js/lib/src/services/reservations.ts +++ b/crates/trusted-server-js/lib/src/services/reservations.ts @@ -697,7 +697,6 @@ export function createReservationService(options: ReservationServiceOptions): Re const publishOwnerRegistration = ( generation: object, - existing: OwnerRegistration | undefined, registration: OwnerRegistration ): boolean => { try { @@ -710,7 +709,6 @@ export function createReservationService(options: ReservationServiceOptions): Re registration.callbackState.active = false; return false; } - if (existing) existing.callbackState.active = false; return true; }; @@ -732,7 +730,8 @@ export function createReservationService(options: ReservationServiceOptions): Re const initial = readOwnerRegistration(owner.generation); if (!initial.ok) return undefined; const existing = initial.value; - if (existing?.identity === owner.identity) { + if (existing) { + if (existing.identity !== owner.identity) return undefined; const ownerIsCurrent = currentOwner(owner); const currentGeneration = owner.readGeneration(); const current = readOwnerRegistration(owner.generation); @@ -757,7 +756,7 @@ export function createReservationService(options: ReservationServiceOptions): Re ready: false, }; const generation = owner.generation; - if (!publishOwnerRegistration(generation, existing, registration)) return undefined; + if (!publishOwnerRegistration(generation, registration)) return undefined; try { owner.onDispose('reservation', () => disposeOwnerEntries(generation, callbackState)); } catch { diff --git a/crates/trusted-server-js/lib/test/services/reservations.test.ts b/crates/trusted-server-js/lib/test/services/reservations.test.ts index 56c932524..1e146311f 100644 --- a/crates/trusted-server-js/lib/test/services/reservations.test.ts +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -685,7 +685,61 @@ describe('renderer reservation identity and registration', () => { }); }); - it('makes a late expired owner callback token-safe after the same id is reused', () => { + 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({}); @@ -713,10 +767,20 @@ describe('renderer reservation identity and registration', () => { isCurrent: () => true, onDispose: vi.fn(), }; - expect(service.registerRender({ ...input, navigation: newOwner })).toMatchObject({ ok: true }); + expect(service.registerRender({ ...input, navigation: newOwner })).toEqual({ + ok: false, + reason: 'stale_owner', + }); + expect(newOwner.onDispose).not.toHaveBeenCalled(); oldCleanup?.(); - expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); }); }); From 0231bdf936b7cc4e8feb2724e9cc9a02a5406017 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 05:37:24 -0700 Subject: [PATCH 039/194] Implement render attempt lifecycle --- .../lib/src/kernel/sessions.ts | 5 + .../lib/src/services/render.ts | 1127 +++++++++++++++++ .../lib/test/kernel/sessions.test.ts | 2 + .../lib/test/services/render.test.ts | 747 +++++++++++ ...8-04-aps-tsjs-resilience-implementation.md | 2 + ...s-render-fix-and-tsjs-resilience-design.md | 16 +- 6 files changed, 1893 insertions(+), 6 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/services/render.ts create mode 100644 crates/trusted-server-js/lib/test/services/render.test.ts diff --git a/crates/trusted-server-js/lib/src/kernel/sessions.ts b/crates/trusted-server-js/lib/src/kernel/sessions.ts index fa67e3bd0..a301cf9d1 100644 --- a/crates/trusted-server-js/lib/src/kernel/sessions.ts +++ b/crates/trusted-server-js/lib/src/kernel/sessions.ts @@ -128,6 +128,7 @@ export interface AuctionBatchScope { /** Attempt-owned scope for timers, listeners, ports, and one terminal lifecycle. */ export interface RenderAttemptScope { readonly generation: object; + readonly navigationGeneration: object; readonly interfaces: RuntimeInterfaces; readonly id: string; readonly slot: string; @@ -228,6 +229,7 @@ class RenderAttemptOwner implements RenderAttemptScope { public constructor( public readonly id: string, public readonly slot: string, + public readonly navigationGeneration: object, public readonly interfaces: RuntimeInterfaces, private readonly ownerIsCurrent: () => boolean, onDisposalError?: DisposalErrorHandler @@ -337,6 +339,7 @@ class AuctionBatchOwner implements AuctionBatchScope { public constructor( private readonly issuer: NavigationIdentityIssuer, + private readonly navigationGeneration: object, public readonly interfaces: RuntimeInterfaces, private readonly ownerIsCurrent: () => boolean, private readonly attemptExists: (slot: string) => boolean, @@ -370,6 +373,7 @@ class AuctionBatchOwner implements AuctionBatchScope { const attempt = new RenderAttemptOwner( identity.value, slot, + this.navigationGeneration, this.interfaces, (): boolean => this.isCurrent() && this.attempts.get(slot) === attemptReference.current, this.onDisposalError @@ -490,6 +494,7 @@ class NavigationSessionOwner implements NavigationSession { const batchReference: { current?: AuctionBatchOwner } = {}; const batch = new AuctionBatchOwner( issuer, + this.generation, this.interfaces, (): boolean => this.isCurrent() && this.batches.get(key) === batchReference.current, (slot) => this.attempts.has(slot), diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts new file mode 100644 index 000000000..48e98c181 --- /dev/null +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -0,0 +1,1127 @@ +import type { RenderAttemptScope, WinnerContext } from '../kernel/sessions'; + +import type { ReservationRenderSource } from './reservations'; + +const ATTEMPT_ID = /^a1_[A-Za-z0-9_-]{22}$/; +const objectFreezeIntrinsic = Object.freeze; +const arrayIncludesIntrinsic = Array.prototype.includes; +const artifactDisposals = new WeakMap(); + +function frozen(value: Value): Readonly { + return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; +} + +export const RENDER_FAILURE_REASONS = frozen([ + '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', +] as const); + +export type RenderFailureReason = (typeof RENDER_FAILURE_REASONS)[number]; + +export const RENDER_CANCELLATION_REASONS = frozen([ + 'caller_aborted', + 'superseded', + 'navigation_disposed', +] as const); + +export type RenderCancellationReason = (typeof RENDER_CANCELLATION_REASONS)[number]; + +export type RenderOutcome = + | Readonly<{ outcome: 'accepted' }> + | Readonly<{ outcome: 'no_bid' }> + | Readonly<{ outcome: 'failed'; reason: RenderFailureReason }> + | Readonly<{ outcome: 'cancelled'; reason: RenderCancellationReason }>; + +export type RenderAttemptActiveState = + | 'created' + | 'waiting_for_gam_and_claim' + | 'waiting_for_owner' + | 'waiting_for_insertion' + | 'rendering_direct' + | 'waiting_for_document' + | 'waiting_for_aps_completion' + | 'waiting_for_adm'; + +export type RenderAttemptState = + RenderAttemptActiveState | 'accepted' | 'no_bid' | 'failed' | 'cancelled'; + +export interface CommittedRenderArtifact { + readonly kind: 'direct_iframe' | 'puc'; + readonly attemptId: string; + readonly slot: string; + readonly navigationGeneration: object; + readonly dispose: () => void; +} + +export interface CommittedArtifactStore { + readonly promote: (artifact: CommittedRenderArtifact, stillCurrent?: () => boolean) => boolean; + readonly current: (slot: string) => CommittedRenderArtifact | undefined; + readonly release: (artifact: CommittedRenderArtifact) => boolean; + readonly disposeNavigation: (navigationGeneration: object) => void; + readonly dispose: () => void; +} + +export interface RenderDeadline { + readonly milliseconds: number; + readonly reason: RenderFailureReason; +} + +export interface RenderScheduler { + readonly set: (callback: () => void, milliseconds: number) => unknown; + readonly clear: (handle: unknown) => void; +} + +/** Fixed deadlines owned by the state transition that enters each wait. */ +export const RENDER_STATE_DEADLINES: Readonly< + Partial> +> = frozen({ + waiting_for_owner: frozen({ + milliseconds: 3_000, + reason: 'owner_registration_timeout', + }), + waiting_for_insertion: frozen({ + milliseconds: 1_000, + reason: 'owner_insertion_timeout', + }), + waiting_for_document: frozen({ + milliseconds: 3_000, + reason: 'renderer_document_no_load', + }), + waiting_for_aps_completion: frozen({ milliseconds: 10_000, reason: 'runner_failed' }), + waiting_for_adm: frozen({ milliseconds: 5_000, reason: 'adm_document_no_load' }), +}); + +export interface RenderAttemptOptions { + readonly owner: RenderAttemptScope; + readonly artifacts: CommittedArtifactStore; + readonly prepareRenderSource: (candidate: unknown) => ReservationRenderSource | undefined; + readonly parentAttemptId?: string; + readonly scheduler?: RenderScheduler; +} + +export type RenderAttemptCreationResult = + | Readonly<{ ok: true; value: RenderAttempt }> + | Readonly<{ + ok: false; + reason: 'identity_generation_failed' | 'invalid_attempt' | 'stale_owner'; + }>; + +export interface RenderAttemptSnapshot { + readonly history: readonly RenderAttemptState[]; + readonly outcome: RenderOutcome | undefined; + readonly state: RenderAttemptState; +} + +export interface RenderAttempt { + readonly id: string; + readonly slot: string; + readonly generation: object; + readonly navigationGeneration: object; + readonly parentAttemptId: string | undefined; + readonly renderSource: ReservationRenderSource | undefined; + readonly winnerContext: WinnerContext | undefined; + readonly admitDirectWinner: (source: unknown, context: WinnerContext) => boolean; + readonly admitClaimedWinner: (source: unknown) => boolean; + readonly beginGamClaim: () => boolean; + readonly ownerClaimed: () => boolean; + readonly ownerRegistered: () => boolean; + readonly beginDirect: () => boolean; + readonly beginApsDocument: (artifact: CommittedRenderArtifact) => boolean; + readonly beginAdm: (artifact: CommittedRenderArtifact) => boolean; + readonly apsDocumentAccepted: () => boolean; + readonly accept: () => boolean; + readonly noBid: () => boolean; + readonly fail: (reason: RenderFailureReason) => boolean; + readonly cancel: (reason: RenderCancellationReason) => boolean; + readonly onSettled: (callback: (outcome: RenderOutcome) => void) => boolean; + readonly snapshot: () => RenderAttemptSnapshot; +} + +export interface SlotOperationResult { + readonly path: 'primary' | 'fallback'; + readonly outcome: RenderOutcome; + readonly primaryAttemptId: string; + readonly primary: RenderOutcome; + readonly fallbackAttemptId?: string; + readonly fallback?: RenderOutcome; +} + +export interface SlotOperationSnapshot { + readonly settled: boolean; + readonly result?: SlotOperationResult; +} + +export interface SlotOperation { + readonly snapshot: () => SlotOperationSnapshot; + readonly onSettled: (callback: (result: SlotOperationResult) => void) => boolean; +} + +export interface SlotOperationOptions { + readonly primary: RenderAttempt; + readonly createFallback?: (parentAttemptId: string) => RenderAttemptCreationResult; +} + +function validAttemptId(value: unknown): value is string { + return typeof value === 'string' && ATTEMPT_ID.test(value); +} + +function validFailureReason(value: unknown): value is RenderFailureReason { + return ( + typeof value === 'string' && + (Reflect.apply(arrayIncludesIntrinsic, RENDER_FAILURE_REASONS, [value]) as boolean) + ); +} + +function validCancellationReason(value: unknown): value is RenderCancellationReason { + return ( + typeof value === 'string' && + (Reflect.apply(arrayIncludesIntrinsic, RENDER_CANCELLATION_REASONS, [value]) as boolean) + ); +} + +function validOutcome(value: unknown): value is RenderOutcome { + try { + if ( + typeof value !== 'object' || + value === null || + !Object.isFrozen(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return false; + } + const names = Object.getOwnPropertyNames(value); + const outcome = Object.getOwnPropertyDescriptor(value, 'outcome'); + if (!outcome || !('value' in outcome) || outcome.enumerable !== true) return false; + if (outcome.value === 'accepted' || outcome.value === 'no_bid') { + return names.length === 1; + } + const reason = Object.getOwnPropertyDescriptor(value, 'reason'); + if (!reason || !('value' in reason) || reason.enumerable !== true || names.length !== 2) { + return false; + } + return outcome.value === 'failed' + ? validFailureReason(reason.value) + : outcome.value === 'cancelled' && validCancellationReason(reason.value); + } catch { + return false; + } +} + +function permitsPromotion(stillCurrent: (() => boolean) | undefined): boolean { + if (!stillCurrent) return true; + try { + return stillCurrent() === true; + } catch { + return false; + } +} + +function validArtifact( + value: unknown, + slot?: string, + navigationGeneration?: object, + attemptId?: string +): value is CommittedRenderArtifact { + try { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return false; + if (!Object.isFrozen(value) || Object.getPrototypeOf(value) !== Object.prototype) return false; + const names = Object.getOwnPropertyNames(value).sort(); + if ( + names.length !== 5 || + names[0] !== 'attemptId' || + names[1] !== 'dispose' || + names[2] !== 'kind' || + names[3] !== 'navigationGeneration' || + names[4] !== 'slot' || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return false; + } + for (const name of names) { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) return false; + } + const artifact = value as CommittedRenderArtifact; + return ( + (artifact.kind === 'direct_iframe' || artifact.kind === 'puc') && + validAttemptId(artifact.attemptId) && + typeof artifact.slot === 'string' && + artifact.slot.length > 0 && + (typeof artifact.navigationGeneration === 'object' || + typeof artifact.navigationGeneration === 'function') && + artifact.navigationGeneration !== null && + typeof artifact.dispose === 'function' && + (slot === undefined || artifact.slot === slot) && + (navigationGeneration === undefined || + artifact.navigationGeneration === navigationGeneration) && + (attemptId === undefined || artifact.attemptId === attemptId) + ); + } catch { + return false; + } +} + +function disposeArtifact(artifact: CommittedRenderArtifact | undefined): boolean { + if (!artifact) return true; + try { + if (artifactDisposals.has(artifact)) return artifactDisposals.get(artifact) === true; + artifactDisposals.set(artifact, false); + } catch { + return false; + } + try { + artifact.dispose(); + artifactDisposals.set(artifact, true); + return true; + } catch { + return false; + } +} + +/** Own committed artifacts independently from terminal attempt scopes. */ +export function createCommittedArtifactStore(): CommittedArtifactStore { + const entries = new Map(); + const pendingNavigationDisposals = new Set(); + let disposed = false; + let mutating = false; + let disposeRequested = false; + + const disposeGeneration = (navigationGeneration: object): void => { + const snapshot = Array.from(entries.entries()); + for (const [slot, artifact] of snapshot) { + if ( + artifact.navigationGeneration === navigationGeneration && + entries.get(slot) === artifact + ) { + entries.delete(slot); + disposeArtifact(artifact); + } + } + }; + + const drainDeferredDisposal = (): void => { + if (mutating) return; + if (disposeRequested) { + disposeRequested = false; + const snapshot = Array.from(entries.values()); + entries.clear(); + disposed = true; + pendingNavigationDisposals.clear(); + for (const artifact of snapshot) disposeArtifact(artifact); + return; + } + const generations = Array.from(pendingNavigationDisposals); + pendingNavigationDisposals.clear(); + if (generations.length === 0) return; + mutating = true; + try { + for (const generation of generations) disposeGeneration(generation); + } finally { + mutating = false; + if (disposeRequested || pendingNavigationDisposals.size > 0) drainDeferredDisposal(); + } + }; + + const store: CommittedArtifactStore = { + promote(artifact, stillCurrent): boolean { + if (disposed || mutating || !validArtifact(artifact)) return false; + const existing = entries.get(artifact.slot); + if (existing === artifact) return false; + mutating = true; + try { + if (existing) { + if (!disposeArtifact(existing) || entries.get(artifact.slot) !== existing) return false; + } + if ( + disposeRequested || + pendingNavigationDisposals.has(artifact.navigationGeneration) || + !permitsPromotion(stillCurrent) + ) { + if (existing && entries.get(artifact.slot) === existing) entries.delete(artifact.slot); + return false; + } + entries.set(artifact.slot, artifact); + return entries.get(artifact.slot) === artifact; + } catch { + return false; + } finally { + mutating = false; + drainDeferredDisposal(); + } + }, + current(slot): CommittedRenderArtifact | undefined { + if (disposed || typeof slot !== 'string') return undefined; + try { + return entries.get(slot); + } catch { + return undefined; + } + }, + release(artifact): boolean { + if (disposed || mutating || !validArtifact(artifact)) return false; + mutating = true; + try { + if (entries.get(artifact.slot) !== artifact) return false; + entries.delete(artifact.slot); + disposeArtifact(artifact); + return entries.get(artifact.slot) !== artifact; + } catch { + return false; + } finally { + mutating = false; + drainDeferredDisposal(); + } + }, + disposeNavigation(navigationGeneration): void { + if (disposed) return; + pendingNavigationDisposals.add(navigationGeneration); + if (mutating) return; + mutating = true; + try { + pendingNavigationDisposals.delete(navigationGeneration); + disposeGeneration(navigationGeneration); + } catch { + // Disposal is best-effort and never publishes a replacement artifact. + } finally { + mutating = false; + drainDeferredDisposal(); + } + }, + dispose(): void { + if (disposed) return; + disposeRequested = true; + if (mutating) return; + mutating = true; + try { + const snapshot = Array.from(entries.values()); + entries.clear(); + disposeRequested = false; + disposed = true; + pendingNavigationDisposals.clear(); + for (const artifact of snapshot) disposeArtifact(artifact); + } catch { + disposeRequested = false; + disposed = true; + entries.clear(); + } finally { + mutating = false; + } + }, + }; + return frozen(store); +} + +function defaultScheduler(): RenderScheduler { + return frozen({ + set: (callback: () => void, milliseconds: number): unknown => + globalThis.setTimeout(callback, milliseconds), + clear: (handle: unknown): void => { + globalThis.clearTimeout(handle as ReturnType); + }, + }); +} + +function terminalState(outcome: RenderOutcome): RenderAttemptState { + return outcome.outcome; +} + +/** Construct one path-independent attempt lifecycle around an issued owner scope. */ +export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemptCreationResult { + let owner: RenderAttemptScope; + let artifacts: CommittedArtifactStore; + let id: string; + let slot: string; + let generation: object; + let navigationGeneration: object; + let parentAttemptId: string | undefined; + let prepareRenderSource: (candidate: unknown) => ReservationRenderSource | undefined; + let ownerIsCurrentMethod: RenderAttemptScope['isCurrent']; + let ownerDisposeMethod: RenderAttemptScope['dispose']; + let ownerOnDisposeMethod: RenderAttemptScope['onDispose']; + let ownerPrepareWinnerMethod: RenderAttemptScope['prepareWinnerContext']; + let promoteArtifactMethod: CommittedArtifactStore['promote']; + let currentArtifactMethod: CommittedArtifactStore['current']; + let releaseArtifactMethod: CommittedArtifactStore['release']; + try { + owner = options.owner; + artifacts = options.artifacts; + id = owner.id; + slot = owner.slot; + generation = owner.generation; + navigationGeneration = owner.navigationGeneration; + parentAttemptId = options.parentAttemptId; + prepareRenderSource = options.prepareRenderSource; + ownerIsCurrentMethod = owner.isCurrent; + ownerDisposeMethod = owner.dispose; + ownerOnDisposeMethod = owner.onDispose; + ownerPrepareWinnerMethod = owner.prepareWinnerContext; + promoteArtifactMethod = artifacts.promote; + currentArtifactMethod = artifacts.current; + releaseArtifactMethod = artifacts.release; + if ( + !validAttemptId(id) || + typeof slot !== 'string' || + slot.length === 0 || + (typeof generation !== 'object' && typeof generation !== 'function') || + generation === null || + (typeof navigationGeneration !== 'object' && typeof navigationGeneration !== 'function') || + navigationGeneration === null || + generation === navigationGeneration || + typeof promoteArtifactMethod !== 'function' || + typeof currentArtifactMethod !== 'function' || + typeof releaseArtifactMethod !== 'function' || + typeof ownerIsCurrentMethod !== 'function' || + typeof ownerDisposeMethod !== 'function' || + typeof ownerOnDisposeMethod !== 'function' || + typeof ownerPrepareWinnerMethod !== 'function' || + typeof prepareRenderSource !== 'function' || + (parentAttemptId !== undefined && + (!validAttemptId(parentAttemptId) || parentAttemptId === id)) + ) { + return frozen({ ok: false, reason: 'invalid_attempt' }); + } + } catch { + return frozen({ ok: false, reason: 'invalid_attempt' }); + } + const ownerIsCurrent = (): boolean => { + try { + return Reflect.apply(ownerIsCurrentMethod, owner, []) === true; + } catch { + return false; + } + }; + if (!ownerIsCurrent()) return frozen({ ok: false, reason: 'stale_owner' }); + + const scheduler = options.scheduler ?? defaultScheduler(); + let schedulerSetMethod: RenderScheduler['set']; + let schedulerClearMethod: RenderScheduler['clear']; + try { + schedulerSetMethod = scheduler.set; + schedulerClearMethod = scheduler.clear; + if (typeof schedulerSetMethod !== 'function' || typeof schedulerClearMethod !== 'function') { + return frozen({ ok: false, reason: 'invalid_attempt' }); + } + } catch { + return frozen({ ok: false, reason: 'invalid_attempt' }); + } + const history: RenderAttemptState[] = ['created']; + const observers: Array<(outcome: RenderOutcome) => void> = []; + let state: RenderAttemptState = 'created'; + let outcome: RenderOutcome | undefined; + let pendingArtifact: CommittedRenderArtifact | undefined; + let admittedRenderSource: ReservationRenderSource | undefined; + let admittedWinnerContext: WinnerContext | undefined; + let deadlineHandle: unknown; + let deadlineState: RenderAttemptActiveState | undefined; + let settlingInternally = false; + + const prepareSource = (candidate: unknown): ReservationRenderSource | undefined => { + try { + const source = prepareRenderSource(candidate); + if (!source || !Object.isFrozen(source)) return undefined; + const type = Object.getOwnPropertyDescriptor(source, 'type'); + const version = Object.getOwnPropertyDescriptor(source, 'version'); + if ( + !type || + !('value' in type) || + (type.value !== 'aps' && type.value !== 'adm' && type.value !== 'cache') || + !version || + !('value' in version) || + version.value !== 1 + ) { + return undefined; + } + return source; + } catch { + return undefined; + } + }; + + const validWinnerContext = (context: unknown): context is WinnerContext => { + try { + if ( + (typeof context !== 'object' && typeof context !== 'function') || + context === null || + !Object.isFrozen(context) || + Object.getPrototypeOf(context) !== Object.prototype || + Object.getOwnPropertyNames(context).length !== 1 || + Object.getOwnPropertySymbols(context).length !== 0 + ) { + return false; + } + const selectedCpm = Object.getOwnPropertyDescriptor(context, 'selectedCpm'); + return ( + !!selectedCpm && + 'value' in selectedCpm && + selectedCpm.enumerable === true && + typeof selectedCpm.value === 'number' && + Number.isFinite(selectedCpm.value) && + selectedCpm.value >= 0 + ); + } catch { + return false; + } + }; + + const readWinnerContext = (): WinnerContext | undefined => { + try { + return owner.winnerContext; + } catch { + return undefined; + } + }; + + const admitDirectWinner = (candidate: unknown, context: WinnerContext): boolean => { + if ( + state !== 'created' || + outcome !== undefined || + admittedRenderSource !== undefined || + admittedWinnerContext !== undefined || + !ownerIsCurrent() || + !validWinnerContext(context) + ) { + return false; + } + const source = prepareSource(candidate); + if (!source) return false; + let admission: ReturnType; + try { + admission = Reflect.apply(ownerPrepareWinnerMethod, owner, [context]); + } catch { + return false; + } + if (!admission) return false; + let committed: boolean; + try { + committed = admission.commit() === true; + } catch { + committed = false; + } + if (!committed || !ownerIsCurrent() || readWinnerContext() !== context) { + try { + admission.rollback(); + } catch { + // Failed admission retains no lifecycle source/context authority. + } + return false; + } + admittedRenderSource = source; + admittedWinnerContext = context; + return true; + }; + + const admitClaimedWinner = (candidate: unknown): boolean => { + if ( + state !== 'waiting_for_gam_and_claim' || + outcome !== undefined || + admittedRenderSource !== undefined || + admittedWinnerContext !== undefined || + !ownerIsCurrent() + ) { + return false; + } + const context = readWinnerContext(); + if (!validWinnerContext(context)) return false; + const source = prepareSource(candidate); + if (!source || !ownerIsCurrent() || readWinnerContext() !== context) return false; + admittedRenderSource = source; + admittedWinnerContext = context; + return true; + }; + + const clearDeadline = (): void => { + if (deadlineState === undefined) return; + const handle = deadlineHandle; + deadlineHandle = undefined; + deadlineState = undefined; + try { + Reflect.apply(schedulerClearMethod, scheduler, [handle]); + } catch { + // A cleared logical deadline remains inert even when the host clear throws. + } + }; + + const notify = (terminal: RenderOutcome): void => { + const snapshot = observers.splice(0, observers.length); + for (const observer of snapshot) { + try { + observer(terminal); + } catch { + // Observation cannot change the terminal result. + } + } + }; + + const settle = (terminal: RenderOutcome, disposeOwner: boolean): boolean => { + if (outcome !== undefined) return false; + outcome = terminal; + state = terminalState(terminal); + history.push(state); + clearDeadline(); + admittedRenderSource = undefined; + admittedWinnerContext = undefined; + if (terminal.outcome !== 'accepted') { + const uncommitted = pendingArtifact; + pendingArtifact = undefined; + disposeArtifact(uncommitted); + } + if (disposeOwner) { + settlingInternally = true; + try { + Reflect.apply(ownerDisposeMethod, owner, []); + } catch { + // The terminal latch and owned-resource cleanup remain authoritative. + } finally { + settlingInternally = false; + } + } + notify(terminal); + return true; + }; + + const fail = (reason: RenderFailureReason): boolean => + validFailureReason(reason) && (reason !== 'gam_empty' || state === 'waiting_for_gam_and_claim') + ? settle(frozen({ outcome: 'failed', reason }), true) + : false; + + const armDeadline = (entered: RenderAttemptActiveState): void => { + const deadline = RENDER_STATE_DEADLINES[entered]; + if (!deadline) return; + deadlineState = entered; + try { + const handle = Reflect.apply(schedulerSetMethod, scheduler, [ + () => { + if (outcome === undefined && state === entered && deadlineState === entered) { + fail(deadline.reason); + } + }, + deadline.milliseconds, + ]); + if (outcome === undefined && state === entered && deadlineState === entered) { + deadlineHandle = handle; + } else { + try { + Reflect.apply(schedulerClearMethod, scheduler, [handle]); + } catch { + // A synchronously-settled deadline remains inert through the terminal latch. + } + } + } catch { + deadlineState = undefined; + deadlineHandle = undefined; + fail('internal_error'); + } + }; + + const enter = ( + allowed: readonly RenderAttemptActiveState[], + next: RenderAttemptActiveState + ): boolean => { + if (outcome !== undefined || !ownerIsCurrent() || !allowed.includes(state as never)) { + return false; + } + state = next; + history.push(next); + clearDeadline(); + if (outcome === undefined) armDeadline(next); + return true; + }; + + const stageAndEnter = ( + allowed: readonly RenderAttemptActiveState[], + next: 'waiting_for_document' | 'waiting_for_adm', + artifact: CommittedRenderArtifact + ): boolean => { + if ( + pendingArtifact !== undefined || + !validArtifact(artifact, slot, navigationGeneration, id) || + outcome !== undefined || + !ownerIsCurrent() || + !allowed.includes(state as never) + ) { + return false; + } + pendingArtifact = artifact; + if (enter(allowed, next)) return true; + pendingArtifact = undefined; + return false; + }; + + const lifecycle: RenderAttempt = { + id, + slot, + generation, + navigationGeneration, + parentAttemptId, + get renderSource(): ReservationRenderSource | undefined { + return admittedRenderSource; + }, + get winnerContext(): WinnerContext | undefined { + return admittedWinnerContext; + }, + admitDirectWinner, + admitClaimedWinner, + beginGamClaim: () => enter(['created'], 'waiting_for_gam_and_claim'), + ownerClaimed: () => + admittedRenderSource && admittedWinnerContext + ? enter(['waiting_for_gam_and_claim'], 'waiting_for_owner') + : false, + ownerRegistered: () => enter(['waiting_for_owner'], 'waiting_for_insertion'), + beginDirect: () => + admittedRenderSource && admittedWinnerContext + ? enter(['created'], 'rendering_direct') + : false, + beginApsDocument: (artifact) => + stageAndEnter( + ['waiting_for_insertion', 'rendering_direct'], + 'waiting_for_document', + artifact + ), + beginAdm: (artifact) => + stageAndEnter(['waiting_for_insertion', 'rendering_direct'], 'waiting_for_adm', artifact), + apsDocumentAccepted: () => enter(['waiting_for_document'], 'waiting_for_aps_completion'), + accept(): boolean { + if ( + outcome !== undefined || + !ownerIsCurrent() || + (state !== 'waiting_for_aps_completion' && state !== 'waiting_for_adm') || + !pendingArtifact + ) { + return false; + } + clearDeadline(); + const candidate = pendingArtifact; + let promoted: boolean; + try { + promoted = Reflect.apply(promoteArtifactMethod, artifacts, [ + candidate, + () => + outcome === undefined && + pendingArtifact === candidate && + (state === 'waiting_for_aps_completion' || state === 'waiting_for_adm') && + ownerIsCurrent(), + ]); + promoted = + promoted && Reflect.apply(currentArtifactMethod, artifacts, [slot]) === candidate; + if ( + promoted && + (outcome !== undefined || + pendingArtifact !== candidate || + (state !== 'waiting_for_aps_completion' && state !== 'waiting_for_adm') || + !ownerIsCurrent()) + ) { + Reflect.apply(releaseArtifactMethod, artifacts, [candidate]); + promoted = false; + } + } catch { + promoted = false; + } + if (!promoted) { + fail('internal_error'); + return false; + } + pendingArtifact = undefined; + return settle(frozen({ outcome: 'accepted' }), true); + }, + noBid: () => + state === 'created' && + admittedRenderSource === undefined && + admittedWinnerContext === undefined && + ownerIsCurrent() + ? settle(frozen({ outcome: 'no_bid' }), true) + : false, + fail, + cancel: (reason) => + validCancellationReason(reason) + ? settle(frozen({ outcome: 'cancelled', reason }), true) + : false, + onSettled(callback): boolean { + if (typeof callback !== 'function') return false; + if (outcome) { + try { + callback(outcome); + } catch { + // Observation cannot change the terminal result. + } + return true; + } + observers.push(callback); + return true; + }, + snapshot: () => + frozen({ + history: frozen(history.slice()), + outcome, + state, + }), + }; + + try { + Reflect.apply(ownerOnDisposeMethod, owner, [ + 'render-lifecycle', + () => { + if (!settlingInternally && outcome === undefined) { + settle(frozen({ outcome: 'cancelled', reason: 'navigation_disposed' }), false); + } + }, + ]); + } catch { + return frozen({ ok: false, reason: 'stale_owner' }); + } + if (!ownerIsCurrent()) { + settle(frozen({ outcome: 'cancelled', reason: 'navigation_disposed' }), false); + return frozen({ ok: false, reason: 'stale_owner' }); + } + return frozen({ ok: true, value: frozen(lifecycle) }); +} + +/** Own one public per-slot result without overwriting either child attempt result. */ +export function createSlotOperation(options: SlotOperationOptions): SlotOperation { + const observers: Array<(result: SlotOperationResult) => void> = []; + const primary = options.primary; + const primaryId = primary.id; + const primarySlot = primary.slot; + const primaryNavigationGeneration = primary.navigationGeneration; + const primaryOnSettledMethod = primary.onSettled; + const primarySnapshotMethod = primary.snapshot; + const createFallback = options.createFallback; + let result: SlotOperationResult | undefined; + + const settle = (terminal: SlotOperationResult): boolean => { + if (result) return false; + result = frozen(terminal); + const snapshot = observers.splice(0, observers.length); + for (const observer of snapshot) { + try { + observer(result); + } catch { + // Observation cannot change the public result. + } + } + return true; + }; + + const settleFallbackFailure = (primary: RenderOutcome, reason: RenderFailureReason): void => { + settle({ + path: 'fallback', + outcome: frozen({ outcome: 'failed', reason }), + primaryAttemptId: primaryId, + primary, + }); + }; + + const beginFallback = (primary: RenderOutcome): void => { + let created: RenderAttemptCreationResult; + try { + created = + (createFallback ? Reflect.apply(createFallback, options, [primaryId]) : undefined) ?? + frozen({ ok: false, reason: 'stale_owner' }); + } catch { + settleFallbackFailure(primary, 'internal_error'); + return; + } + let createdOk: boolean; + try { + createdOk = created.ok === true; + } catch { + settleFallbackFailure(primary, 'internal_error'); + return; + } + if (!createdOk) { + let reason: 'identity_generation_failed' | 'invalid_attempt' | 'stale_owner'; + try { + reason = (created as Extract).reason; + } catch { + settleFallbackFailure(primary, 'internal_error'); + return; + } + settleFallbackFailure( + primary, + reason === 'identity_generation_failed' ? 'identity_generation_failed' : 'internal_error' + ); + return; + } + let child: RenderAttempt; + let childId: string; + let childOnSettledMethod: RenderAttempt['onSettled']; + let childCancelMethod: RenderAttempt['cancel']; + try { + child = (created as Extract).value; + childId = child.id; + childOnSettledMethod = child.onSettled; + childCancelMethod = child.cancel; + if ( + !validAttemptId(childId) || + childId === primaryId || + child.slot !== primarySlot || + child.parentAttemptId !== primaryId || + child.navigationGeneration !== primaryNavigationGeneration || + typeof childOnSettledMethod !== 'function' || + typeof childCancelMethod !== 'function' + ) { + throw new TypeError('invalid fallback child'); + } + } catch { + try { + const candidate = (created as Partial>) + .value as Partial | undefined; + if (candidate && typeof candidate.cancel === 'function') { + Reflect.apply(candidate.cancel, candidate, ['superseded']); + } + } catch { + // Invalid fallback cleanup cannot change the operation result. + } + settleFallbackFailure(primary, 'internal_error'); + return; + } + try { + const subscribed = Reflect.apply(childOnSettledMethod, child, [ + (fallbackOutcome: RenderOutcome) => { + if (!validOutcome(fallbackOutcome)) { + settleFallbackFailure(primary, 'internal_error'); + return; + } + settle({ + path: 'fallback', + outcome: fallbackOutcome, + primaryAttemptId: primaryId, + primary, + fallbackAttemptId: childId, + fallback: fallbackOutcome, + }); + }, + ]); + if (subscribed !== true && !result) settleFallbackFailure(primary, 'internal_error'); + } catch { + settleFallbackFailure(primary, 'internal_error'); + } + }; + + try { + const subscribed = Reflect.apply(primaryOnSettledMethod, primary, [ + (primaryOutcome: RenderOutcome) => { + if (!validOutcome(primaryOutcome)) { + const internal = frozen({ + outcome: 'failed' as const, + reason: 'internal_error' as const, + }); + settle({ + path: 'primary', + outcome: internal, + primaryAttemptId: primaryId, + primary: internal, + }); + return; + } + let attributableGamEmpty = false; + if ( + primaryOutcome.outcome === 'failed' && + primaryOutcome.reason === 'gam_empty' && + typeof createFallback === 'function' + ) { + try { + const snapshot = Reflect.apply(primarySnapshotMethod, primary, []); + attributableGamEmpty = + snapshot.outcome === primaryOutcome && + snapshot.state === 'failed' && + snapshot.history[snapshot.history.length - 2] === 'waiting_for_gam_and_claim'; + } catch { + attributableGamEmpty = false; + } + } + if (attributableGamEmpty) { + beginFallback(primaryOutcome); + return; + } + settle({ + path: 'primary', + outcome: primaryOutcome, + primaryAttemptId: primaryId, + primary: primaryOutcome, + }); + }, + ]); + if (subscribed !== true && !result) { + const internal = frozen({ outcome: 'failed' as const, reason: 'internal_error' as const }); + settle({ + path: 'primary', + outcome: internal, + primaryAttemptId: primaryId, + primary: internal, + }); + } + } catch { + const internal = frozen({ outcome: 'failed' as const, reason: 'internal_error' as const }); + settle({ + path: 'primary', + outcome: internal, + primaryAttemptId: primaryId, + primary: internal, + }); + } + + return frozen({ + snapshot: (): SlotOperationSnapshot => + result ? frozen({ settled: true, result }) : frozen({ settled: false }), + onSettled(callback: (terminal: SlotOperationResult) => void): boolean { + if (typeof callback !== 'function') return false; + if (result) { + try { + callback(result); + } catch { + // Observation cannot change the public result. + } + } else { + observers.push(callback); + } + return true; + }, + }); +} diff --git a/crates/trusted-server-js/lib/test/kernel/sessions.test.ts b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts index 633510963..4fc6e31ca 100644 --- a/crates/trusted-server-js/lib/test/kernel/sessions.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts @@ -289,6 +289,8 @@ describe('runtime and navigation sessions', () => { 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')); 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..f39b7e37a --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -0,0 +1,747 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { RenderAttemptScope, WinnerContext } from '../../src/kernel/sessions'; +import { + createCommittedArtifactStore, + createRenderAttempt, + createSlotOperation, + type CommittedRenderArtifact, + type RenderAttempt, + type RenderAttemptState, +} from '../../src/services/render'; + +const ATTEMPT_ONE = 'a1_0000000000000000000000'; +const ATTEMPT_TWO = 'a1_0000000000000000000001'; + +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 WINNER_CONTEXT = Object.freeze({ selectedCpm: 1 }); + +function prepareRenderSource(candidate: unknown) { + if (candidate === ADM_SOURCE) return ADM_SOURCE; + if (candidate === APS_SOURCE) return APS_SOURCE; + return undefined; +} + +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() || winnerContext !== undefined) return undefined; + let committed = false; + return Object.freeze({ + commit: () => { + if (committed) return winnerContext === context; + if (!scope.isCurrent() || winnerContext !== undefined) return false; + winnerContext = context; + committed = true; + return true; + }, + rollback: () => { + if (committed && winnerContext === context) winnerContext = undefined; + committed = false; + return winnerContext === undefined; + }, + }); + }, + 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 result = createRenderAttempt({ + artifacts: options.artifacts ?? createCommittedArtifactStore(), + owner: scope, + prepareRenderSource: options.prepareRenderSource ?? prepareRenderSource, + ...(options.parentAttemptId === undefined ? {} : { parentAttemptId: options.parentAttemptId }), + ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), + }); + expect(result).toMatchObject({ ok: true }); + if (!result.ok) throw new Error('should create an attempt'); + return result.value; +} + +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); + const render = attempt(scope); + const observed: RenderAttemptState[] = []; + + expect(render.beginGamClaim()).toBe(true); + expect(render.beginDirect()).toBe(false); + scope.admitClaimedContext(WINNER_CONTEXT); + expect(render.admitClaimedWinner(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); + pucOwner.admitClaimedContext(WINNER_CONTEXT); + expect(puc.admitClaimedWinner(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('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(); + registrationOwner.admitClaimedContext(WINNER_CONTEXT); + registration.admitClaimedWinner(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(); + insertionOwner.admitClaimedContext(WINNER_CONTEXT); + insertion.admitClaimedWinner(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('rejects malformed or stale attempt ownership before registering work', () => { + const malformed = owner('bad-attempt'); + expect( + createRenderAttempt({ + owner: malformed, + artifacts: createCommittedArtifactStore(), + prepareRenderSource, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + + const stale = owner(); + stale.disposeFromNavigation(); + expect( + createRenderAttempt({ + owner: stale, + artifacts: createCommittedArtifactStore(), + prepareRenderSource, + }) + ).toEqual({ ok: false, reason: 'stale_owner' }); + }); + + 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(); + }); +}); + +describe('SlotOperation result isolation', () => { + it('retains immutable primary gam_empty and settles from one distinct fallback child', () => { + const primary = attempt(); + let fallback: RenderAttempt | undefined; + const operation = createSlotOperation({ + primary, + createFallback: (parentAttemptId) => { + const childOwner = owner(ATTEMPT_TWO, primary.slot, primary.navigationGeneration); + const result = createRenderAttempt({ + owner: childOwner, + artifacts: createCommittedArtifactStore(), + 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 = createSlotOperation({ 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 = createSlotOperation({ 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 = createSlotOperation({ + primary, + createFallback: (parentAttemptId) => { + const result = createRenderAttempt({ + owner: owner(ATTEMPT_TWO), + artifacts: createCommittedArtifactStore(), + 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 = createSlotOperation({ + primary, + createFallback: () => ({ 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 = createSlotOperation({ + 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 = createSlotOperation({ + primary: subscriptionPrimary, + createFallback: () => ({ ok: true, value: hostileChild }), + }); + subscriptionPrimary.beginGamClaim(); + subscriptionPrimary.fail('gam_empty'); + expect(subscriptionOperation.snapshot()).toMatchObject({ + settled: true, + result: { outcome: { outcome: 'failed', reason: 'internal_error' } }, + }); + }); +}); diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index bfa9d74e8..4e14d498d 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -1369,6 +1369,8 @@ Every task's regression suite therefore remains green in task order. construction assert one exact navigation-unique `a1_` attempt id; fallback child ids are distinct and bind their exact parent id. Test navigation-prefix failure, ordinal exhaustion, disposal, and that neither ids nor issuer bytes reach logs. + Treat `created -> no_bid` as the sole `no_bid` transition for an exact parsed + server no-winner decision; every later no-bid transition is invalid. Add renderer-nonce live-registry tests at 255/256/257 entries, eight collision draws, crypto failure, exact source/port/attempt/generation binding, disposal reuse, diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index 7d81582cb..0a4a3bccc 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1361,7 +1361,7 @@ global `postMessage` acknowledgement. ```text created - -> waiting_for_gam_and_claim | rendering_direct + -> no_bid | waiting_for_gam_and_claim | rendering_direct | failed | cancelled waiting_for_gam_and_claim -> waiting_for_owner | failed | cancelled waiting_for_owner @@ -1378,6 +1378,14 @@ waiting_for_adm -> accepted | failed | cancelled ``` +`no_bid` is terminal and is valid only as `created -> no_bid`: it records the exact, +successfully parsed server decision that the slot has no winner before any render path +starts. It is invalid from every later state; GAM empty, transport, timeout, parse, +descriptor, and renderer failures retain their explicit failure outcome. +`failed` and `cancelled` remain valid from `created` so an auction child can settle +when its exact server decision fails or its caller/batch/navigation cancels before a +render path begins. + Transitions are methods on `RenderAttempt`, not ad-hoc flag mutation. Each method checks the expected state and terminal latch. Timers are created at the transition whose deadline they enforce and are cleared by the transition that settles them. @@ -2454,11 +2462,7 @@ subscription methods. The final schema is: ```ts type RenderTracePathV1 = 'auction' | 'ssat' | 'gam-refresh' type RenderTraceServedFromV1 = - | 'inline' - | 'gam' - | 'debug-adm' - | 'pbs-cache' - | 'prebid' + 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid' interface RenderTraceRecord { readonly slotId: string From b817f6814d0d30faca8946cb8ca6f567839cacd3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:05:29 -0700 Subject: [PATCH 040/194] Harden render lifecycle transactions --- .../lib/src/services/render.ts | 591 +++++++++++++++--- .../lib/src/services/reservations.ts | 72 +++ .../lib/test/services/render.test.ts | 565 ++++++++++++++++- .../lib/test/services/reservations.test.ts | 58 ++ ...8-04-aps-tsjs-resilience-implementation.md | 6 +- ...s-render-fix-and-tsjs-resilience-design.md | 19 +- 6 files changed, 1205 insertions(+), 106 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index 48e98c181..2fe8bf9c5 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -1,16 +1,147 @@ import type { RenderAttemptScope, WinnerContext } from '../kernel/sessions'; -import type { ReservationRenderSource } from './reservations'; +import type { + ReservationClaimAdmission, + ReservationClaimExpectation, + ReservationRenderSource, +} from './reservations'; const ATTEMPT_ID = /^a1_[A-Za-z0-9_-]{22}$/; const objectFreezeIntrinsic = Object.freeze; const arrayIncludesIntrinsic = Array.prototype.includes; +const mapGetIntrinsic = Map.prototype.get; +const mapSetIntrinsic = Map.prototype.set; +const mapDeleteIntrinsic = Map.prototype.delete; +const mapClearIntrinsic = Map.prototype.clear; +const mapEntriesIntrinsic = Map.prototype.entries; +const mapValuesIntrinsic = Map.prototype.values; +const mapEntryIteratorNextIntrinsic = Object.getPrototypeOf(new Map().entries()).next as ( + this: IterableIterator +) => IteratorResult; +const mapValueIteratorNextIntrinsic = Object.getPrototypeOf(new Map().values()).next as ( + this: IterableIterator +) => IteratorResult; +const setAddIntrinsic = Set.prototype.add; +const setHasIntrinsic = Set.prototype.has; +const setDeleteIntrinsic = Set.prototype.delete; +const setClearIntrinsic = Set.prototype.clear; +const setValuesIntrinsic = Set.prototype.values; +const setValueIteratorNextIntrinsic = Object.getPrototypeOf(new Set().values()).next as ( + this: IterableIterator +) => IteratorResult; +const weakMapGetIntrinsic = WeakMap.prototype.get; +const weakMapSetIntrinsic = WeakMap.prototype.set; +const weakMapHasIntrinsic = WeakMap.prototype.has; +const weakSetAddIntrinsic = WeakSet.prototype.add; +const weakSetHasIntrinsic = WeakSet.prototype.has; +const promiseThenIntrinsic = Promise.prototype.then; const artifactDisposals = new WeakMap(); +const committedArtifactStores = new WeakSet(); +const renderAttempts = new WeakSet(); function frozen(value: Value): Readonly { return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; } +function mapGet(map: Map, key: Key): Value | undefined { + return Reflect.apply(mapGetIntrinsic, map, [key]) as Value | undefined; +} + +function mapSet(map: Map, key: Key, value: Value): void { + Reflect.apply(mapSetIntrinsic, map, [key, value]); +} + +function mapDelete(map: Map, key: Key): boolean { + return Reflect.apply(mapDeleteIntrinsic, map, [key]) as boolean; +} + +function mapClear(map: Map): void { + Reflect.apply(mapClearIntrinsic, map, []); +} + +function mapEntrySnapshot(map: Map): Array<[Key, Value]> { + const iterator = Reflect.apply(mapEntriesIntrinsic, map, []) as IterableIterator<[Key, Value]>; + const output: Array<[Key, Value]> = []; + while (true) { + const step = Reflect.apply(mapEntryIteratorNextIntrinsic, iterator, []) as IteratorResult< + [Key, Value] + >; + if (step.done) return output; + output[output.length] = step.value; + } +} + +function mapValueSnapshot(map: Map): Value[] { + const iterator = Reflect.apply(mapValuesIntrinsic, map, []) as IterableIterator; + const output: Value[] = []; + while (true) { + const step = Reflect.apply( + mapValueIteratorNextIntrinsic, + iterator, + [] + ) as IteratorResult; + if (step.done) return output; + output[output.length] = step.value; + } +} + +function setAdd(set: Set, value: Value): void { + Reflect.apply(setAddIntrinsic, set, [value]); +} + +function setHas(set: Set, value: Value): boolean { + return Reflect.apply(setHasIntrinsic, set, [value]) as boolean; +} + +function setDelete(set: Set, value: Value): boolean { + return Reflect.apply(setDeleteIntrinsic, set, [value]) as boolean; +} + +function setClear(set: Set): void { + Reflect.apply(setClearIntrinsic, set, []); +} + +function setValueSnapshot(set: Set): Value[] { + const iterator = Reflect.apply(setValuesIntrinsic, set, []) as IterableIterator; + const output: Value[] = []; + while (true) { + const step = Reflect.apply( + setValueIteratorNextIntrinsic, + iterator, + [] + ) as IteratorResult; + if (step.done) return output; + output[output.length] = step.value; + } +} + +function weakMapGet( + map: WeakMap, + key: Key +): Value | undefined { + return Reflect.apply(weakMapGetIntrinsic, map, [key]) as Value | undefined; +} + +function weakMapSet( + map: WeakMap, + key: Key, + value: Value +): void { + Reflect.apply(weakMapSetIntrinsic, map, [key, value]); +} + +function weakMapHas(map: WeakMap, key: Key): boolean { + return Reflect.apply(weakMapHasIntrinsic, map, [key]) as boolean; +} + +function weakSetAdd(set: WeakSet, value: Value): void { + Reflect.apply(weakSetAddIntrinsic, set, [value]); +} + +function weakSetHas(set: WeakSet, value: Value): boolean { + return Reflect.apply(weakSetHasIntrinsic, set, [value]) as boolean; +} + export const RENDER_FAILURE_REASONS = frozen([ 'auction_timeout', 'auction_disabled', @@ -141,6 +272,10 @@ export interface RenderAttemptOptions { readonly owner: RenderAttemptScope; readonly artifacts: CommittedArtifactStore; readonly prepareRenderSource: (candidate: unknown) => ReservationRenderSource | undefined; + readonly consumeClaimedWinner: ( + claim: unknown, + expectation: ReservationClaimExpectation + ) => ReservationClaimAdmission | undefined; readonly parentAttemptId?: string; readonly scheduler?: RenderScheduler; } @@ -202,6 +337,9 @@ export interface SlotOperation { readonly onSettled: (callback: (result: SlotOperationResult) => void) => boolean; } +export type SlotOperationCreationResult = + Readonly<{ ok: true; value: SlotOperation }> | Readonly<{ ok: false; reason: 'invalid_attempt' }>; + export interface SlotOperationOptions { readonly primary: RenderAttempt; readonly createFallback?: (parentAttemptId: string) => RenderAttemptCreationResult; @@ -311,36 +449,70 @@ function validArtifact( function disposeArtifact(artifact: CommittedRenderArtifact | undefined): boolean { if (!artifact) return true; try { - if (artifactDisposals.has(artifact)) return artifactDisposals.get(artifact) === true; - artifactDisposals.set(artifact, false); + if (weakMapHas(artifactDisposals, artifact)) { + return weakMapGet(artifactDisposals, artifact) === true; + } + weakMapSet(artifactDisposals, artifact, false); } catch { return false; } try { - artifact.dispose(); - artifactDisposals.set(artifact, true); + const result = Reflect.apply(artifact.dispose, artifact, []) as unknown; + if ((typeof result === 'object' || typeof result === 'function') && result !== null) { + try { + Reflect.apply(promiseThenIntrinsic, result, [undefined, () => undefined]); + return false; + } catch { + // Non-Promise thenables are contained through their own `then` method below. + } + let thenMethod: unknown; + try { + thenMethod = Reflect.get(result, 'then'); + } catch { + return false; + } + if (typeof thenMethod === 'function') { + try { + Reflect.apply(thenMethod, result, [undefined, () => undefined]); + } catch { + // A hostile thenable is still an unsupported asynchronous disposer. + } + return false; + } + } + if (result !== undefined) return false; + weakMapSet(artifactDisposals, artifact, true); return true; } catch { return false; } } +function artifactDisposalStarted(artifact: CommittedRenderArtifact): boolean { + try { + return weakMapHas(artifactDisposals, artifact); + } catch { + return true; + } +} + /** Own committed artifacts independently from terminal attempt scopes. */ export function createCommittedArtifactStore(): CommittedArtifactStore { const entries = new Map(); const pendingNavigationDisposals = new Set(); + const disposedNavigations = new WeakSet(); let disposed = false; let mutating = false; let disposeRequested = false; const disposeGeneration = (navigationGeneration: object): void => { - const snapshot = Array.from(entries.entries()); + const snapshot = mapEntrySnapshot(entries); for (const [slot, artifact] of snapshot) { if ( artifact.navigationGeneration === navigationGeneration && - entries.get(slot) === artifact + mapGet(entries, slot) === artifact ) { - entries.delete(slot); + mapDelete(entries, slot); disposeArtifact(artifact); } } @@ -350,111 +522,160 @@ export function createCommittedArtifactStore(): CommittedArtifactStore { if (mutating) return; if (disposeRequested) { disposeRequested = false; - const snapshot = Array.from(entries.values()); - entries.clear(); + const snapshot = mapValueSnapshot(entries); + mapClear(entries); disposed = true; - pendingNavigationDisposals.clear(); + setClear(pendingNavigationDisposals); for (const artifact of snapshot) disposeArtifact(artifact); return; } - const generations = Array.from(pendingNavigationDisposals); - pendingNavigationDisposals.clear(); + const generations = setValueSnapshot(pendingNavigationDisposals); + setClear(pendingNavigationDisposals); if (generations.length === 0) return; mutating = true; try { for (const generation of generations) disposeGeneration(generation); } finally { mutating = false; - if (disposeRequested || pendingNavigationDisposals.size > 0) drainDeferredDisposal(); + if (disposeRequested || setValueSnapshot(pendingNavigationDisposals).length > 0) { + drainDeferredDisposal(); + } } }; const store: CommittedArtifactStore = { promote(artifact, stillCurrent): boolean { - if (disposed || mutating || !validArtifact(artifact)) return false; - const existing = entries.get(artifact.slot); - if (existing === artifact) return false; - mutating = true; + let ownsMutation = false; try { - if (existing) { - if (!disposeArtifact(existing) || entries.get(artifact.slot) !== existing) return false; + if ( + disposed || + mutating || + !validArtifact(artifact) || + artifactDisposalStarted(artifact) || + weakSetHas(disposedNavigations, artifact.navigationGeneration) || + !permitsPromotion(stillCurrent) + ) { + return false; } + const existing = mapGet(entries, artifact.slot); + if (existing === artifact) return false; + mutating = true; + ownsMutation = true; if ( + disposed || disposeRequested || - pendingNavigationDisposals.has(artifact.navigationGeneration) || + weakSetHas(disposedNavigations, artifact.navigationGeneration) || + setHas(pendingNavigationDisposals, artifact.navigationGeneration) || !permitsPromotion(stillCurrent) ) { - if (existing && entries.get(artifact.slot) === existing) entries.delete(artifact.slot); return false; } - entries.set(artifact.slot, artifact); - return entries.get(artifact.slot) === artifact; + if (existing) { + if (!disposeArtifact(existing) || mapGet(entries, artifact.slot) !== existing) + return false; + } + if ( + disposed || + disposeRequested || + weakSetHas(disposedNavigations, artifact.navigationGeneration) || + setHas(pendingNavigationDisposals, artifact.navigationGeneration) || + !permitsPromotion(stillCurrent) || + artifactDisposalStarted(artifact) + ) { + if (existing && mapGet(entries, artifact.slot) === existing) { + mapDelete(entries, artifact.slot); + } + return false; + } + if (existing && mapGet(entries, artifact.slot) === existing) { + mapDelete(entries, artifact.slot); + } + mapSet(entries, artifact.slot, artifact); + return mapGet(entries, artifact.slot) === artifact; } catch { return false; } finally { - mutating = false; - drainDeferredDisposal(); + if (ownsMutation) { + mutating = false; + drainDeferredDisposal(); + } } }, current(slot): CommittedRenderArtifact | undefined { - if (disposed || typeof slot !== 'string') return undefined; try { - return entries.get(slot); + if (disposed || typeof slot !== 'string') return undefined; + return mapGet(entries, slot); } catch { return undefined; } }, release(artifact): boolean { - if (disposed || mutating || !validArtifact(artifact)) return false; - mutating = true; + let ownsMutation = false; try { - if (entries.get(artifact.slot) !== artifact) return false; - entries.delete(artifact.slot); - disposeArtifact(artifact); - return entries.get(artifact.slot) !== artifact; + if (disposed || mutating || !validArtifact(artifact)) return false; + mutating = true; + ownsMutation = true; + if (mapGet(entries, artifact.slot) !== artifact) return false; + mapDelete(entries, artifact.slot); + const cleaned = disposeArtifact(artifact); + return cleaned && mapGet(entries, artifact.slot) !== artifact; } catch { return false; } finally { - mutating = false; - drainDeferredDisposal(); + if (ownsMutation) { + mutating = false; + drainDeferredDisposal(); + } } }, disposeNavigation(navigationGeneration): void { - if (disposed) return; - pendingNavigationDisposals.add(navigationGeneration); - if (mutating) return; - mutating = true; + let ownsMutation = false; try { - pendingNavigationDisposals.delete(navigationGeneration); + if (disposed) return; + weakSetAdd(disposedNavigations, navigationGeneration); + setAdd(pendingNavigationDisposals, navigationGeneration); + if (mutating) return; + mutating = true; + ownsMutation = true; + setDelete(pendingNavigationDisposals, navigationGeneration); disposeGeneration(navigationGeneration); } catch { // Disposal is best-effort and never publishes a replacement artifact. } finally { - mutating = false; - drainDeferredDisposal(); + if (ownsMutation) { + mutating = false; + drainDeferredDisposal(); + } } }, dispose(): void { - if (disposed) return; - disposeRequested = true; - if (mutating) return; - mutating = true; + let ownsMutation = false; try { - const snapshot = Array.from(entries.values()); - entries.clear(); + if (disposed) return; + disposeRequested = true; + if (mutating) return; + mutating = true; + ownsMutation = true; + const snapshot = mapValueSnapshot(entries); + mapClear(entries); disposeRequested = false; disposed = true; - pendingNavigationDisposals.clear(); + setClear(pendingNavigationDisposals); for (const artifact of snapshot) disposeArtifact(artifact); } catch { disposeRequested = false; disposed = true; - entries.clear(); + try { + mapClear(entries); + } catch { + // The store remains terminal even under collection corruption. + } } finally { - mutating = false; + if (ownsMutation) mutating = false; } }, }; + weakSetAdd(committedArtifactStores, store); return frozen(store); } @@ -482,6 +703,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp let navigationGeneration: object; let parentAttemptId: string | undefined; let prepareRenderSource: (candidate: unknown) => ReservationRenderSource | undefined; + let consumeClaimedWinner: RenderAttemptOptions['consumeClaimedWinner']; let ownerIsCurrentMethod: RenderAttemptScope['isCurrent']; let ownerDisposeMethod: RenderAttemptScope['dispose']; let ownerOnDisposeMethod: RenderAttemptScope['onDispose']; @@ -492,12 +714,16 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp try { owner = options.owner; artifacts = options.artifacts; + if (!weakSetHas(committedArtifactStores, artifacts)) { + return frozen({ ok: false, reason: 'invalid_attempt' }); + } id = owner.id; slot = owner.slot; generation = owner.generation; navigationGeneration = owner.navigationGeneration; parentAttemptId = options.parentAttemptId; prepareRenderSource = options.prepareRenderSource; + consumeClaimedWinner = options.consumeClaimedWinner; ownerIsCurrentMethod = owner.isCurrent; ownerDisposeMethod = owner.dispose; ownerOnDisposeMethod = owner.onDispose; @@ -522,6 +748,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp typeof ownerOnDisposeMethod !== 'function' || typeof ownerPrepareWinnerMethod !== 'function' || typeof prepareRenderSource !== 'function' || + typeof consumeClaimedWinner !== 'function' || (parentAttemptId !== undefined && (!validAttemptId(parentAttemptId) || parentAttemptId === id)) ) { @@ -537,6 +764,19 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp return false; } }; + const ownerIdentityIsCurrent = (): boolean => { + try { + return ( + owner.id === id && + owner.slot === slot && + owner.generation === generation && + owner.navigationGeneration === navigationGeneration && + ownerIsCurrent() + ); + } catch { + return false; + } + }; if (!ownerIsCurrent()) return frozen({ ok: false, reason: 'stale_owner' }); const scheduler = options.scheduler ?? defaultScheduler(); @@ -584,6 +824,57 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp } }; + const claimedSource = ( + admission: unknown, + context: WinnerContext + ): ReservationRenderSource | undefined => { + try { + if ( + typeof admission !== 'object' || + admission === null || + !Object.isFrozen(admission) || + Object.getPrototypeOf(admission) !== Object.prototype || + Object.getOwnPropertySymbols(admission).length !== 0 + ) { + return undefined; + } + const names = Object.getOwnPropertyNames(admission).sort(); + const renderSource = Object.getOwnPropertyDescriptor(admission, 'renderSource'); + const winnerContext = Object.getOwnPropertyDescriptor(admission, 'winnerContext'); + if ( + names.length !== 2 || + names[0] !== 'renderSource' || + names[1] !== 'winnerContext' || + !renderSource || + !('value' in renderSource) || + renderSource.enumerable !== true || + !winnerContext || + !('value' in winnerContext) || + winnerContext.enumerable !== true || + winnerContext.value !== context + ) { + return undefined; + } + const source = renderSource.value as ReservationRenderSource; + if (!Object.isFrozen(source)) return undefined; + const type = Object.getOwnPropertyDescriptor(source, 'type'); + const version = Object.getOwnPropertyDescriptor(source, 'version'); + if ( + !type || + !('value' in type) || + (type.value !== 'aps' && type.value !== 'adm' && type.value !== 'cache') || + !version || + !('value' in version) || + version.value !== 1 + ) { + return undefined; + } + return source; + } catch { + return undefined; + } + }; + const validWinnerContext = (context: unknown): context is WinnerContext => { try { if ( @@ -669,7 +960,21 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp } const context = readWinnerContext(); if (!validWinnerContext(context)) return false; - const source = prepareSource(candidate); + let admission: ReservationClaimAdmission | undefined; + try { + admission = consumeClaimedWinner( + candidate, + frozen({ + attemptId: id, + slot, + navigationGeneration, + winnerContext: context, + }) + ); + } catch { + return false; + } + const source = claimedSource(admission, context); if (!source || !ownerIsCurrent() || readWinnerContext() !== context) return false; admittedRenderSource = source; admittedWinnerContext = context; @@ -779,9 +1084,17 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp next: 'waiting_for_document' | 'waiting_for_adm', artifact: CommittedRenderArtifact ): boolean => { + const sourceType = admittedRenderSource?.type; + const sourceMatches = + next === 'waiting_for_document' + ? sourceType === 'aps' + : sourceType === 'adm' || sourceType === 'cache'; + const artifactKind = state === 'rendering_direct' ? 'direct_iframe' : 'puc'; if ( pendingArtifact !== undefined || !validArtifact(artifact, slot, navigationGeneration, id) || + artifact.kind !== artifactKind || + !sourceMatches || outcome !== undefined || !ownerIsCurrent() || !allowed.includes(state as never) @@ -808,7 +1121,10 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp }, admitDirectWinner, admitClaimedWinner, - beginGamClaim: () => enter(['created'], 'waiting_for_gam_and_claim'), + beginGamClaim: () => + admittedRenderSource === undefined && admittedWinnerContext === undefined + ? enter(['created'], 'waiting_for_gam_and_claim') + : false, ownerClaimed: () => admittedRenderSource && admittedWinnerContext ? enter(['waiting_for_gam_and_claim'], 'waiting_for_owner') @@ -836,10 +1152,20 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp ) { return false; } - clearDeadline(); const candidate = pendingArtifact; + clearDeadline(); + if ( + outcome !== undefined || + pendingArtifact !== candidate || + (state !== 'waiting_for_aps_completion' && state !== 'waiting_for_adm') || + !ownerIsCurrent() + ) { + return false; + } let promoted: boolean; + let promotionAttempted = false; try { + promotionAttempted = true; promoted = Reflect.apply(promoteArtifactMethod, artifacts, [ candidate, () => @@ -857,13 +1183,19 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp (state !== 'waiting_for_aps_completion' && state !== 'waiting_for_adm') || !ownerIsCurrent()) ) { - Reflect.apply(releaseArtifactMethod, artifacts, [candidate]); promoted = false; } } catch { promoted = false; } if (!promoted) { + if (promotionAttempted) { + try { + Reflect.apply(releaseArtifactMethod, artifacts, [candidate]); + } catch { + // The branded store contains release failure; attempt cleanup remains exact-once. + } + } fail('internal_error'); return false; } @@ -903,6 +1235,14 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp }), }; + const disposeRejectedOwner = (): void => { + try { + Reflect.apply(ownerDisposeMethod, owner, []); + } catch { + // Owner registration failure is terminal even if host disposal throws. + } + }; + try { Reflect.apply(ownerOnDisposeMethod, owner, [ 'render-lifecycle', @@ -913,25 +1253,53 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp }, ]); } catch { + disposeRejectedOwner(); return frozen({ ok: false, reason: 'stale_owner' }); } - if (!ownerIsCurrent()) { + if (outcome !== undefined || !ownerIdentityIsCurrent()) { settle(frozen({ outcome: 'cancelled', reason: 'navigation_disposed' }), false); + disposeRejectedOwner(); return frozen({ ok: false, reason: 'stale_owner' }); } + weakSetAdd(renderAttempts, lifecycle); return frozen({ ok: true, value: frozen(lifecycle) }); } /** Own one public per-slot result without overwriting either child attempt result. */ -export function createSlotOperation(options: SlotOperationOptions): SlotOperation { +export function createSlotOperation(options: SlotOperationOptions): SlotOperationCreationResult { const observers: Array<(result: SlotOperationResult) => void> = []; - const primary = options.primary; - const primaryId = primary.id; - const primarySlot = primary.slot; - const primaryNavigationGeneration = primary.navigationGeneration; - const primaryOnSettledMethod = primary.onSettled; - const primarySnapshotMethod = primary.snapshot; - const createFallback = options.createFallback; + let primary: RenderAttempt; + let primaryId: string; + let primarySlot: string; + let primaryNavigationGeneration: object; + let primaryOnSettledMethod: RenderAttempt['onSettled']; + let primarySnapshotMethod: RenderAttempt['snapshot']; + let createFallback: SlotOperationOptions['createFallback']; + try { + const primaryDescriptor = Object.getOwnPropertyDescriptor(options, 'primary'); + const fallbackDescriptor = Object.getOwnPropertyDescriptor(options, 'createFallback'); + if (!primaryDescriptor || !('value' in primaryDescriptor)) { + return frozen({ ok: false, reason: 'invalid_attempt' }); + } + primary = primaryDescriptor.value as RenderAttempt; + if (!weakSetHas(renderAttempts, primary)) { + return frozen({ ok: false, reason: 'invalid_attempt' }); + } + primaryId = primary.id; + primarySlot = primary.slot; + primaryNavigationGeneration = primary.navigationGeneration; + primaryOnSettledMethod = primary.onSettled; + primarySnapshotMethod = primary.snapshot; + createFallback = + fallbackDescriptor && 'value' in fallbackDescriptor + ? (fallbackDescriptor.value as SlotOperationOptions['createFallback']) + : undefined; + if (createFallback !== undefined && typeof createFallback !== 'function') { + return frozen({ ok: false, reason: 'invalid_attempt' }); + } + } catch { + return frozen({ ok: false, reason: 'invalid_attempt' }); + } let result: SlotOperationResult | undefined; const settle = (terminal: SlotOperationResult): boolean => { @@ -958,7 +1326,7 @@ export function createSlotOperation(options: SlotOperationOptions): SlotOperatio }; const beginFallback = (primary: RenderOutcome): void => { - let created: RenderAttemptCreationResult; + let created: unknown; try { created = (createFallback ? Reflect.apply(createFallback, options, [primaryId]) : undefined) ?? @@ -968,32 +1336,77 @@ export function createSlotOperation(options: SlotOperationOptions): SlotOperatio return; } let createdOk: boolean; + let createdValue: unknown; + let creationReason: unknown; try { - createdOk = created.ok === true; + if ( + (typeof created !== 'object' && typeof created !== 'function') || + created === null || + !Object.isFrozen(created) || + Object.getPrototypeOf(created) !== Object.prototype || + Object.getOwnPropertySymbols(created).length !== 0 + ) { + throw new TypeError('invalid fallback result'); + } + const ok = Object.getOwnPropertyDescriptor(created, 'ok'); + if (!ok || !('value' in ok) || ok.enumerable !== true || typeof ok.value !== 'boolean') { + throw new TypeError('invalid fallback result'); + } + createdOk = ok.value; + const field = Object.getOwnPropertyDescriptor(created, createdOk ? 'value' : 'reason'); + if (!field || !('value' in field) || field.enumerable !== true) { + throw new TypeError('invalid fallback result'); + } + const names = Object.getOwnPropertyNames(created).sort(); + if ( + names.length !== 2 || + names[0] !== 'ok' || + names[1] !== (createdOk ? 'value' : 'reason') + ) { + throw new TypeError('invalid fallback result'); + } + if (createdOk) createdValue = field.value; + else creationReason = field.value; } catch { settleFallbackFailure(primary, 'internal_error'); return; } if (!createdOk) { - let reason: 'identity_generation_failed' | 'invalid_attempt' | 'stale_owner'; - try { - reason = (created as Extract).reason; - } catch { - settleFallbackFailure(primary, 'internal_error'); - return; - } settleFallbackFailure( primary, - reason === 'identity_generation_failed' ? 'identity_generation_failed' : 'internal_error' + creationReason === 'identity_generation_failed' + ? 'identity_generation_failed' + : 'internal_error' ); return; } - let child: RenderAttempt; + let child: RenderAttempt | undefined; let childId: string; let childOnSettledMethod: RenderAttempt['onSettled']; let childCancelMethod: RenderAttempt['cancel']; + let cancelInvoked = false; + const cancelChild = (): void => { + if (cancelInvoked || !child || typeof childCancelMethod !== 'function') return; + cancelInvoked = true; + try { + Reflect.apply(childCancelMethod, child, ['superseded']); + } catch { + // Fallback cleanup cannot change the operation result. + } + }; try { - child = (created as Extract).value; + if ( + (typeof createdValue !== 'object' && typeof createdValue !== 'function') || + createdValue === null + ) { + throw new TypeError('invalid fallback child'); + } + child = createdValue as RenderAttempt; + const cancel = Object.getOwnPropertyDescriptor(child, 'cancel'); + if (cancel && 'value' in cancel && typeof cancel.value === 'function') { + childCancelMethod = cancel.value as RenderAttempt['cancel']; + } + if (!weakSetHas(renderAttempts, child)) throw new TypeError('invalid fallback child'); childId = child.id; childOnSettledMethod = child.onSettled; childCancelMethod = child.cancel; @@ -1009,15 +1422,7 @@ export function createSlotOperation(options: SlotOperationOptions): SlotOperatio throw new TypeError('invalid fallback child'); } } catch { - try { - const candidate = (created as Partial>) - .value as Partial | undefined; - if (candidate && typeof candidate.cancel === 'function') { - Reflect.apply(candidate.cancel, candidate, ['superseded']); - } - } catch { - // Invalid fallback cleanup cannot change the operation result. - } + cancelChild(); settleFallbackFailure(primary, 'internal_error'); return; } @@ -1025,6 +1430,7 @@ export function createSlotOperation(options: SlotOperationOptions): SlotOperatio const subscribed = Reflect.apply(childOnSettledMethod, child, [ (fallbackOutcome: RenderOutcome) => { if (!validOutcome(fallbackOutcome)) { + cancelChild(); settleFallbackFailure(primary, 'internal_error'); return; } @@ -1038,9 +1444,15 @@ export function createSlotOperation(options: SlotOperationOptions): SlotOperatio }); }, ]); - if (subscribed !== true && !result) settleFallbackFailure(primary, 'internal_error'); + if (subscribed !== true && !result) { + cancelChild(); + settleFallbackFailure(primary, 'internal_error'); + } } catch { - settleFallbackFailure(primary, 'internal_error'); + if (!result) { + cancelChild(); + settleFallbackFailure(primary, 'internal_error'); + } } }; @@ -1107,7 +1519,7 @@ export function createSlotOperation(options: SlotOperationOptions): SlotOperatio }); } - return frozen({ + const operation = frozen({ snapshot: (): SlotOperationSnapshot => result ? frozen({ settled: true, result }) : frozen({ settled: false }), onSettled(callback: (terminal: SlotOperationResult) => void): boolean { @@ -1124,4 +1536,5 @@ export function createSlotOperation(options: SlotOperationOptions): SlotOperatio return true; }, }); + return frozen({ ok: true, value: operation }); } diff --git a/crates/trusted-server-js/lib/src/services/reservations.ts b/crates/trusted-server-js/lib/src/services/reservations.ts index 828c9639f..7c4da227f 100644 --- a/crates/trusted-server-js/lib/src/services/reservations.ts +++ b/crates/trusted-server-js/lib/src/services/reservations.ts @@ -29,6 +29,7 @@ const mapSizeGetter = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.ge ) => number; const weakMapGetIntrinsic = WeakMap.prototype.get; const weakMapSetIntrinsic = WeakMap.prototype.set; +const weakMapDeleteIntrinsic = WeakMap.prototype.delete; const performanceNowIntrinsic = performance.now; function mapValue(map: Map, key: Key): Value | undefined { @@ -62,6 +63,13 @@ function setWeakMapValue( Reflect.apply(weakMapSetIntrinsic, map, [key, value]); } +function deleteWeakMapValue( + map: WeakMap, + key: Key +): boolean { + return Reflect.apply(weakMapDeleteIntrinsic, map, [key]) as boolean; +} + function mapValueSnapshot(map: Map): Value[] { const iterator = Reflect.apply(mapValuesIntrinsic, map, []) as IterableIterator; const values: Value[] = []; @@ -305,6 +313,18 @@ export type ReservationClaimResult = expiresAt: number; }>; +export interface ReservationClaimExpectation { + readonly attemptId: string; + readonly slot: string; + readonly navigationGeneration: object; + readonly winnerContext: WinnerContext; +} + +export interface ReservationClaimAdmission { + readonly renderSource: ReservationRenderSource; + readonly winnerContext: WinnerContext; +} + export interface ReservationServiceInventory { readonly clockFaulted: boolean; readonly disposed: boolean; @@ -325,6 +345,10 @@ export interface ReservationService { input: PromotePrebidSelectionInput ) => ReservationRegistrationResult; readonly claim: (input: ReservationClaimInput) => ReservationClaimResult; + readonly consumeClaim: ( + claim: unknown, + expectation: ReservationClaimExpectation + ) => ReservationClaimAdmission | undefined; readonly recognize: (reservationId: unknown) => ReservationRecognition; readonly tombstone: (input: ReservationTombstoneInput, state: 'disposed' | 'stale') => boolean; readonly tombstonePrebidGroup: ( @@ -359,6 +383,14 @@ interface ReservationTombstone { readonly state: ReservationTombstoneState; } +interface ClaimedAdmission { + readonly attemptId: string; + readonly slot: string; + readonly navigationGeneration: object; + readonly renderSource: ReservationRenderSource; + readonly winnerContext: WinnerContext; +} + type ReservationEntry = LiveReservation | ReservationTombstone; interface OwnerSnapshot { @@ -595,6 +627,7 @@ export function createReservationService(options: ReservationServiceOptions): Re const readNow = monotonicClock(nowSource); const entries = new Map(); const ownerRegistrations = new WeakMap(); + const claimAdmissions = new WeakMap(); const disposeStore = (): void => { disposed = true; @@ -1090,8 +1123,47 @@ export function createReservationService(options: ReservationServiceOptions): Re const replacement = mapValue(entries, minimalId); return refusedClaim(replacement?.state ?? 'stale'); } + try { + setWeakMapValue(claimAdmissions, result, { + attemptId: identity.id, + slot: identity.slot, + navigationGeneration: entry.navigationGeneration, + renderSource: entry.renderSource, + winnerContext: entry.winnerContext, + }); + } catch { + storeFaulted = true; + rollbackWinnerAdmission(admission); + return refusedClaim('consumed'); + } return result; }, + consumeClaim(claim, expectation): ReservationClaimAdmission | undefined { + const fields = ownDataRecord(expectation, [ + 'attemptId', + 'slot', + 'navigationGeneration', + 'winnerContext', + ]); + if (!fields || (typeof claim !== 'object' && typeof claim !== 'function') || claim === null) { + return undefined; + } + const admission = weakMapValue(claimAdmissions, claim); + if ( + !admission || + fields.attemptId !== admission.attemptId || + fields.slot !== admission.slot || + fields.navigationGeneration !== admission.navigationGeneration || + fields.winnerContext !== admission.winnerContext + ) { + return undefined; + } + if (!deleteWeakMapValue(claimAdmissions, claim)) return undefined; + return frozenResult({ + renderSource: admission.renderSource, + winnerContext: admission.winnerContext, + }); + }, recognize, tombstone(input, state): boolean { if (!validRenderTombstoneState(state)) return false; diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index f39b7e37a..c3e0e89e8 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -7,7 +7,10 @@ import { createSlotOperation, type CommittedRenderArtifact, type RenderAttempt, + type RenderAttemptOptions, type RenderAttemptState, + type SlotOperation, + type SlotOperationOptions, } from '../../src/services/render'; const ATTEMPT_ONE = 'a1_0000000000000000000000'; @@ -41,6 +44,23 @@ function prepareRenderSource(candidate: unknown) { return undefined; } +const claimedSources = new WeakMap(); + +function claimed(source: typeof ADM_SOURCE | typeof APS_SOURCE): object { + const claim = Object.freeze({}); + claimedSources.set(claim, source); + return claim; +} + +const consumeClaimedWinner: RenderAttemptOptions['consumeClaimedWinner'] = (claim, expectation) => { + if ((typeof claim !== 'object' && typeof claim !== 'function') || claim === null) { + return undefined; + } + const source = claimedSources.get(claim); + if (!source || !claimedSources.delete(claim)) return undefined; + return Object.freeze({ renderSource: source, winnerContext: expectation.winnerContext }); +}; + type TestOwner = RenderAttemptScope & { admitClaimedContext(context: WinnerContext): void; disposeFromNavigation(): void; @@ -136,6 +156,7 @@ function attempt( ): RenderAttempt { const result = createRenderAttempt({ artifacts: options.artifacts ?? createCommittedArtifactStore(), + consumeClaimedWinner: options.consumeClaimedWinner ?? consumeClaimedWinner, owner: scope, prepareRenderSource: options.prepareRenderSource ?? prepareRenderSource, ...(options.parentAttemptId === undefined ? {} : { parentAttemptId: options.parentAttemptId }), @@ -146,17 +167,24 @@ function attempt( return result.value; } +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('RenderAttempt state machine', () => { it('implements the exact PUC APS state table and makes invalid/replay transitions inert', () => { const scope = owner(); - const candidate = artifact(scope); + const candidate = artifact(scope, 'puc'); const render = attempt(scope); const observed: RenderAttemptState[] = []; expect(render.beginGamClaim()).toBe(true); expect(render.beginDirect()).toBe(false); scope.admitClaimedContext(WINNER_CONTEXT); - expect(render.admitClaimedWinner(APS_SOURCE)).toBe(true); + expect(render.admitClaimedWinner(claimed(APS_SOURCE))).toBe(true); expect(render.ownerClaimed()).toBe(true); expect(render.ownerRegistered()).toBe(true); expect(render.beginApsDocument(candidate)).toBe(true); @@ -199,7 +227,7 @@ describe('RenderAttempt state machine', () => { const puc = attempt(pucOwner); expect(puc.beginGamClaim()).toBe(true); pucOwner.admitClaimedContext(WINNER_CONTEXT); - expect(puc.admitClaimedWinner(ADM_SOURCE)).toBe(true); + expect(puc.admitClaimedWinner(claimed(ADM_SOURCE))).toBe(true); expect(puc.ownerClaimed()).toBe(true); expect(puc.ownerRegistered()).toBe(true); expect(puc.beginAdm(pucArtifact)).toBe(true); @@ -214,6 +242,259 @@ describe('RenderAttempt state machine', () => { ]); }); + 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); + pucApsOwner.admitClaimedContext(WINNER_CONTEXT); + expect(pucAps.admitClaimedWinner(claimed(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); + scope.admitClaimedContext(WINNER_CONTEXT); + const exactClaim = claimed(APS_SOURCE); + expect(render.admitClaimedWinner(Object.freeze({}))).toBe(false); + expect(render.admitClaimedWinner(exactClaim)).toBe(true); + expect(render.renderSource).toBe(APS_SOURCE); + expect(render.winnerContext).toBe(WINNER_CONTEXT); + expect(render.admitClaimedWinner(exactClaim)).toBe(false); + + const mismatchedOwner = owner(ATTEMPT_TWO); + const mismatched = attempt(mismatchedOwner, { + consumeClaimedWinner: (_claim, expectation) => + Object.freeze({ + renderSource: ADM_SOURCE, + winnerContext: Object.freeze({ selectedCpm: expectation.winnerContext.selectedCpm }), + }), + }); + expect(mismatched.beginGamClaim()).toBe(true); + mismatchedOwner.admitClaimedContext(WINNER_CONTEXT); + expect(mismatched.admitClaimedWinner(Object.freeze({}))).toBe(false); + expect(mismatched.renderSource).toBeUndefined(); + mismatched.cancel('caller_aborted'); + }); + + 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(); + scope.admitClaimedContext(WINNER_CONTEXT); + render.admitClaimedWinner(claimed(source)); + }; + switch (name) { + case 'created': + break; + case 'created_direct': + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + break; + case 'waiting_for_gam_and_claim': + render.beginGamClaim(); + scope.admitClaimedContext(WINNER_CONTEXT); + 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(claimed(APS_SOURCE)); + 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); @@ -290,7 +571,7 @@ describe('RenderAttempt state machine', () => { const registration = attempt(registrationOwner); registration.beginGamClaim(); registrationOwner.admitClaimedContext(WINNER_CONTEXT); - registration.admitClaimedWinner(APS_SOURCE); + registration.admitClaimedWinner(claimed(APS_SOURCE)); registration.ownerClaimed(); vi.advanceTimersByTime(2_999); expect(registration.snapshot().state).toBe('waiting_for_owner'); @@ -304,7 +585,7 @@ describe('RenderAttempt state machine', () => { const insertion = attempt(insertionOwner); insertion.beginGamClaim(); insertionOwner.admitClaimedContext(WINNER_CONTEXT); - insertion.admitClaimedWinner(APS_SOURCE); + insertion.admitClaimedWinner(claimed(APS_SOURCE)); insertion.ownerClaimed(); insertion.ownerRegistered(); vi.advanceTimersByTime(1_000); @@ -393,12 +674,41 @@ describe('RenderAttempt state machine', () => { 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(), + consumeClaimedWinner, prepareRenderSource, }) ).toEqual({ ok: false, reason: 'invalid_attempt' }); @@ -409,11 +719,41 @@ describe('RenderAttempt state machine', () => { createRenderAttempt({ owner: stale, artifacts: createCommittedArtifactStore(), + consumeClaimedWinner, 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(), + consumeClaimedWinner, + prepareRenderSource, + }) + ).toEqual({ ok: false, reason: 'stale_owner' }); + expect(dispose, mode).toHaveBeenCalledOnce(); + } + }); + it('runtime-rejects invalid terminal reasons instead of publishing malformed outcomes', () => { const render = attempt(); expect(render.fail('invented_failure' as never)).toBe(false); @@ -577,19 +917,181 @@ describe('committed artifact ownership', () => { 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('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('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 = createSlotOperation({ + const operation = slotOperation({ primary, createFallback: (parentAttemptId) => { const childOwner = owner(ATTEMPT_TWO, primary.slot, primary.navigationGeneration); const result = createRenderAttempt({ owner: childOwner, artifacts: createCommittedArtifactStore(), + consumeClaimedWinner, prepareRenderSource, parentAttemptId, }); @@ -626,7 +1128,7 @@ describe('SlotOperation result isolation', () => { it('does not start fallback for ineligible primary results or settle twice', () => { const primary = attempt(); const createFallback = vi.fn(); - const operation = createSlotOperation({ primary, createFallback }); + const operation = slotOperation({ primary, createFallback }); primary.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); primary.beginDirect(); primary.fail('runner_failed'); @@ -645,7 +1147,7 @@ describe('SlotOperation result isolation', () => { it('cannot forge fallback with gam_empty outside an attributable GAM state', () => { const primary = attempt(); const createFallback = vi.fn(); - const operation = createSlotOperation({ primary, createFallback }); + const operation = slotOperation({ primary, createFallback }); expect(primary.fail('gam_empty')).toBe(false); expect(createFallback).not.toHaveBeenCalled(); @@ -656,12 +1158,13 @@ describe('SlotOperation result isolation', () => { it('rejects a fallback child from another navigation generation', () => { const primary = attempt(); let child: RenderAttempt | undefined; - const operation = createSlotOperation({ + const operation = slotOperation({ primary, createFallback: (parentAttemptId) => { const result = createRenderAttempt({ owner: owner(ATTEMPT_TWO), artifacts: createCommittedArtifactStore(), + consumeClaimedWinner, prepareRenderSource, parentAttemptId, }); @@ -687,9 +1190,9 @@ describe('SlotOperation result isolation', () => { it('fails closed when fallback identity issuance fails', () => { const primary = attempt(); - const operation = createSlotOperation({ + const operation = slotOperation({ primary, - createFallback: () => ({ ok: false, reason: 'identity_generation_failed' }), + createFallback: () => Object.freeze({ ok: false, reason: 'identity_generation_failed' }), }); primary.beginGamClaim(); primary.fail('gam_empty'); @@ -706,7 +1209,7 @@ describe('SlotOperation result isolation', () => { it('contains hostile fallback result getters and child subscription failures', () => { const getterPrimary = attempt(); - const getterOperation = createSlotOperation({ + const getterOperation = slotOperation({ primary: getterPrimary, createFallback: () => Object.defineProperty({}, 'ok', { @@ -733,9 +1236,9 @@ describe('SlotOperation result isolation', () => { throw new Error('hostile child subscription'); }, } as unknown as RenderAttempt; - const subscriptionOperation = createSlotOperation({ + const subscriptionOperation = slotOperation({ primary: subscriptionPrimary, - createFallback: () => ({ ok: true, value: hostileChild }), + createFallback: () => Object.freeze({ ok: true, value: hostileChild }), }); subscriptionPrimary.beginGamClaim(); subscriptionPrimary.fail('gam_empty'); @@ -743,5 +1246,39 @@ describe('SlotOperation result isolation', () => { 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 index 1e146311f..ced9d7746 100644 --- a/crates/trusted-server-js/lib/test/services/reservations.test.ts +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -200,6 +200,64 @@ describe('renderer reservation identity and registration', () => { } }); + 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'); + const context = attempt.winnerContext; + if (!context) throw new Error('Expected an admitted winner context'); + + expect( + service.consumeClaim(result, { + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: Object.freeze({}), + winnerContext: context, + }) + ).toBeUndefined(); + expect( + service.consumeClaim(result, { + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: Object.freeze({ selectedCpm: context.selectedCpm }), + }) + ).toBeUndefined(); + expect( + service.consumeClaim(Object.freeze({ ...result }), { + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }) + ).toBeUndefined(); + + const admission = service.consumeClaim(result, { + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }); + expect(admission).toEqual({ + renderSource: result.renderSource, + winnerContext: context, + }); + expect(admission?.renderSource).toBe(result.renderSource); + expect(admission?.winnerContext).toBe(context); + expect(Object.isFrozen(admission)).toBe(true); + expect( + service.consumeClaim(result, { + 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); diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index 4e14d498d..ad190b342 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -1382,7 +1382,8 @@ Every task's regression suite therefore remains green in task order. immutable `WinnerContext{selectedCpm}`, timers, ports, iframe, terminal result, and disposer. Direct winner admission constructs the context from the exact validated joined server winner; a PUC claim receives the same context from the - consumed reservation. + consumed reservation by presenting the exact one-shot frozen claim result; + source and context are never accepted as independently swappable values. Obtain the id only from the NavigationSession issuer before registering work; an issuance failure settles `identity_generation_failed` without DOM/global mutation. Add `SlotOperation` above attempts so a primary and optional fallback @@ -1396,6 +1397,9 @@ Every task's regression suite therefore remains green in task order. without clearing the newer generation. Direct iframe artifacts remove their DOM; PUC artifacts defer DOM ownership to GPT and follow TS-owned destroy/redefine versus publisher-owned metadata-only rules. + Require synchronous exact-once artifact disposal, reject Promise/thenable + disposers, reject republication once disposal starts, and require private + provenance for artifact stores, attempts, and fallback children. - [ ] **Step 3: Implement direct APS:** - validate descriptor before DOM mutation; diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index 0a4a3bccc..f85d659cc 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -650,9 +650,13 @@ finite and nonnegative. Prebid admission verifies that the frozen bid's `cpm` is exactly this stored value, and selection promotion preserves the same context rather than reconstructing it from Prebid. A successful PUC claim transfers the context into the `RenderAttempt` before replacing the live entry with a tombstone. +The exact frozen successful claim result is also a one-shot internal capability: +`RenderAttempt` consumes that object to receive its already-bound render source and +winner context. Callers cannot pass, reconstruct, or swap either field separately. The tombstone discards the render source and winner context and retains only the id, original expiry, terminal state, and minimum suppression metadata. Neither the -descriptor nor any capability contains CPM. +renderer descriptor nor any capability crossing a browser-context boundary contains +CPM; the one-shot claim object remains internal to the same runtime. Ids are unique across all live/tombstoned entries, so lookup identifies one entry and then requires its exact active slot, cycle, and generation. The first compatible @@ -1389,6 +1393,9 @@ render path begins. Transitions are methods on `RenderAttempt`, not ad-hoc flag mutation. Each method checks the expected state and terminal latch. Timers are created at the transition whose deadline they enforce and are cleared by the transition that settles them. +`waiting_for_document` accepts only an APS source; `waiting_for_adm` accepts only an +ADM or cache source. A direct path stages only a `direct_iframe` artifact, while an +owner-controlled PUC path stages only a `puc` artifact. An accepted transition first atomically promotes durable DOM/targeting ownership from the attempt into one `CommittedRenderArtifact` owned by the exact slot and @@ -1403,6 +1410,10 @@ slot publishes another accepted artifact it disposes the prior artifact. Navigat disposes artifacts according to those same ownership/quarantine rules. Claim, registration, owner-control, and renderer-document ports/listeners that are no longer needed close after terminal settlement and are never promoted. +Artifact disposal is synchronous and exact-once. A disposer that throws, returns a +Promise/thenable, or otherwise violates the synchronous contract fails closed and +cannot authorize publication of a replacement or later republication of the disposed +artifact object. ### 4.2 Universal Creative claim @@ -2462,7 +2473,11 @@ subscription methods. The final schema is: ```ts type RenderTracePathV1 = 'auction' | 'ssat' | 'gam-refresh' type RenderTraceServedFromV1 = - 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid' + | 'inline' + | 'gam' + | 'debug-adm' + | 'pbs-cache' + | 'prebid' interface RenderTraceRecord { readonly slotId: string From a895d599a6b8c7da276140f5cc62657b0618e472 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:22:09 -0700 Subject: [PATCH 041/194] Bind render construction authority --- .../lib/src/services/render.ts | 69 ++++-- .../lib/src/services/reservations.ts | 109 +++++++-- .../lib/test/services/render.test.ts | 224 ++++++++++++++---- .../lib/test/services/reservations.test.ts | 55 ++++- ...8-04-aps-tsjs-resilience-implementation.md | 8 +- ...s-render-fix-and-tsjs-resilience-design.md | 10 +- 6 files changed, 374 insertions(+), 101 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index 2fe8bf9c5..b9ce1c31e 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -1,9 +1,11 @@ import type { RenderAttemptScope, WinnerContext } from '../kernel/sessions'; +import { isReservationService } from './reservations'; import type { ReservationClaimAdmission, ReservationClaimExpectation, ReservationRenderSource, + ReservationService, } from './reservations'; const ATTEMPT_ID = /^a1_[A-Za-z0-9_-]{22}$/; @@ -38,6 +40,7 @@ const promiseThenIntrinsic = Promise.prototype.then; const artifactDisposals = new WeakMap(); const committedArtifactStores = new WeakSet(); const renderAttempts = new WeakSet(); +const ignoreAsyncDisposal = (): void => undefined; function frozen(value: Value): Readonly { return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; @@ -272,10 +275,7 @@ export interface RenderAttemptOptions { readonly owner: RenderAttemptScope; readonly artifacts: CommittedArtifactStore; readonly prepareRenderSource: (candidate: unknown) => ReservationRenderSource | undefined; - readonly consumeClaimedWinner: ( - claim: unknown, - expectation: ReservationClaimExpectation - ) => ReservationClaimAdmission | undefined; + readonly reservations: ReservationService; readonly parentAttemptId?: string; readonly scheduler?: RenderScheduler; } @@ -337,6 +337,7 @@ export interface SlotOperation { readonly onSettled: (callback: (result: SlotOperationResult) => void) => boolean; } +/** Result of provenance-checking a primary attempt before operation subscription. */ export type SlotOperationCreationResult = Readonly<{ ok: true; value: SlotOperation }> | Readonly<{ ok: false; reason: 'invalid_attempt' }>; @@ -460,7 +461,7 @@ function disposeArtifact(artifact: CommittedRenderArtifact | undefined): boolean const result = Reflect.apply(artifact.dispose, artifact, []) as unknown; if ((typeof result === 'object' || typeof result === 'function') && result !== null) { try { - Reflect.apply(promiseThenIntrinsic, result, [undefined, () => undefined]); + Reflect.apply(promiseThenIntrinsic, result, [ignoreAsyncDisposal, ignoreAsyncDisposal]); return false; } catch { // Non-Promise thenables are contained through their own `then` method below. @@ -473,7 +474,7 @@ function disposeArtifact(artifact: CommittedRenderArtifact | undefined): boolean } if (typeof thenMethod === 'function') { try { - Reflect.apply(thenMethod, result, [undefined, () => undefined]); + Reflect.apply(thenMethod, result, [ignoreAsyncDisposal, ignoreAsyncDisposal]); } catch { // A hostile thenable is still an unsupported asynchronous disposer. } @@ -696,6 +697,8 @@ function terminalState(outcome: RenderOutcome): RenderAttemptState { /** Construct one path-independent attempt lifecycle around an issued owner scope. */ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemptCreationResult { let owner: RenderAttemptScope; + let ownerForCleanup: unknown; + let ownerDisposeForCleanup: unknown; let artifacts: CommittedArtifactStore; let id: string; let slot: string; @@ -703,7 +706,8 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp let navigationGeneration: object; let parentAttemptId: string | undefined; let prepareRenderSource: (candidate: unknown) => ReservationRenderSource | undefined; - let consumeClaimedWinner: RenderAttemptOptions['consumeClaimedWinner']; + let reservations: ReservationService; + let consumeClaimMethod: ReservationService['consumeClaim']; let ownerIsCurrentMethod: RenderAttemptScope['isCurrent']; let ownerDisposeMethod: RenderAttemptScope['dispose']; let ownerOnDisposeMethod: RenderAttemptScope['onDispose']; @@ -711,11 +715,30 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp let promoteArtifactMethod: CommittedArtifactStore['promote']; let currentArtifactMethod: CommittedArtifactStore['current']; let releaseArtifactMethod: CommittedArtifactStore['release']; + const rejectConstruction = ( + reason: 'invalid_attempt' | 'stale_owner' + ): RenderAttemptCreationResult => { + try { + if ( + ((typeof ownerForCleanup === 'object' && ownerForCleanup !== null) || + typeof ownerForCleanup === 'function') && + typeof ownerDisposeForCleanup === 'function' + ) { + Reflect.apply(ownerDisposeForCleanup, ownerForCleanup, []); + } + } catch { + // Rejection remains authoritative even when issued-owner cleanup throws. + } + return frozen({ ok: false, reason }); + }; try { owner = options.owner; + ownerForCleanup = owner; + ownerDisposeForCleanup = owner.dispose; + ownerDisposeMethod = ownerDisposeForCleanup as RenderAttemptScope['dispose']; artifacts = options.artifacts; if (!weakSetHas(committedArtifactStores, artifacts)) { - return frozen({ ok: false, reason: 'invalid_attempt' }); + return rejectConstruction('invalid_attempt'); } id = owner.id; slot = owner.slot; @@ -723,9 +746,12 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp navigationGeneration = owner.navigationGeneration; parentAttemptId = options.parentAttemptId; prepareRenderSource = options.prepareRenderSource; - consumeClaimedWinner = options.consumeClaimedWinner; + reservations = options.reservations; + if (!isReservationService(reservations)) { + return rejectConstruction('invalid_attempt'); + } + consumeClaimMethod = reservations.consumeClaim; ownerIsCurrentMethod = owner.isCurrent; - ownerDisposeMethod = owner.dispose; ownerOnDisposeMethod = owner.onDispose; ownerPrepareWinnerMethod = owner.prepareWinnerContext; promoteArtifactMethod = artifacts.promote; @@ -748,14 +774,14 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp typeof ownerOnDisposeMethod !== 'function' || typeof ownerPrepareWinnerMethod !== 'function' || typeof prepareRenderSource !== 'function' || - typeof consumeClaimedWinner !== 'function' || + typeof consumeClaimMethod !== 'function' || (parentAttemptId !== undefined && (!validAttemptId(parentAttemptId) || parentAttemptId === id)) ) { - return frozen({ ok: false, reason: 'invalid_attempt' }); + return rejectConstruction('invalid_attempt'); } } catch { - return frozen({ ok: false, reason: 'invalid_attempt' }); + return rejectConstruction('invalid_attempt'); } const ownerIsCurrent = (): boolean => { try { @@ -777,19 +803,20 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp return false; } }; - if (!ownerIsCurrent()) return frozen({ ok: false, reason: 'stale_owner' }); + if (!ownerIsCurrent()) return rejectConstruction('stale_owner'); - const scheduler = options.scheduler ?? defaultScheduler(); + let scheduler: RenderScheduler; let schedulerSetMethod: RenderScheduler['set']; let schedulerClearMethod: RenderScheduler['clear']; try { + scheduler = options.scheduler ?? defaultScheduler(); schedulerSetMethod = scheduler.set; schedulerClearMethod = scheduler.clear; if (typeof schedulerSetMethod !== 'function' || typeof schedulerClearMethod !== 'function') { - return frozen({ ok: false, reason: 'invalid_attempt' }); + return rejectConstruction('invalid_attempt'); } } catch { - return frozen({ ok: false, reason: 'invalid_attempt' }); + return rejectConstruction('invalid_attempt'); } const history: RenderAttemptState[] = ['created']; const observers: Array<(outcome: RenderOutcome) => void> = []; @@ -962,15 +989,15 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp if (!validWinnerContext(context)) return false; let admission: ReservationClaimAdmission | undefined; try { - admission = consumeClaimedWinner( + admission = Reflect.apply(consumeClaimMethod, reservations, [ candidate, - frozen({ + frozen({ attemptId: id, slot, navigationGeneration, winnerContext: context, - }) - ); + }), + ]) as ReservationClaimAdmission | undefined; } catch { return false; } diff --git a/crates/trusted-server-js/lib/src/services/reservations.ts b/crates/trusted-server-js/lib/src/services/reservations.ts index 7c4da227f..feabb9ee8 100644 --- a/crates/trusted-server-js/lib/src/services/reservations.ts +++ b/crates/trusted-server-js/lib/src/services/reservations.ts @@ -29,8 +29,10 @@ const mapSizeGetter = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.ge ) => number; const weakMapGetIntrinsic = WeakMap.prototype.get; const weakMapSetIntrinsic = WeakMap.prototype.set; -const weakMapDeleteIntrinsic = WeakMap.prototype.delete; +const weakSetAddIntrinsic = WeakSet.prototype.add; +const weakSetHasIntrinsic = WeakSet.prototype.has; const performanceNowIntrinsic = performance.now; +const reservationServices = new WeakSet(); function mapValue(map: Map, key: Key): Value | undefined { return Reflect.apply(mapGetIntrinsic, map, [key]) as Value | undefined; @@ -63,11 +65,12 @@ function setWeakMapValue( Reflect.apply(weakMapSetIntrinsic, map, [key, value]); } -function deleteWeakMapValue( - map: WeakMap, - key: Key -): boolean { - return Reflect.apply(weakMapDeleteIntrinsic, map, [key]) as boolean; +function addWeakSetValue(set: WeakSet, value: Value): void { + Reflect.apply(weakSetAddIntrinsic, set, [value]); +} + +function hasWeakSetValue(set: WeakSet, value: Value): boolean { + return Reflect.apply(weakSetHasIntrinsic, set, [value]) as boolean; } function mapValueSnapshot(map: Map): Value[] { @@ -307,12 +310,11 @@ export type ReservationClaimResult = | Readonly<{ recognized: true; claimed: true; - renderSource: ReservationRenderSource; - winnerContext: WinnerContext; pucSource: object; expiresAt: number; }>; +/** Exact lifecycle authority required to consume one successful claim object. */ export interface ReservationClaimExpectation { readonly attemptId: string; readonly slot: string; @@ -320,6 +322,7 @@ export interface ReservationClaimExpectation { readonly winnerContext: WinnerContext; } +/** Source and winner context atomically recovered from one valid claim object. */ export interface ReservationClaimAdmission { readonly renderSource: ReservationRenderSource; readonly winnerContext: WinnerContext; @@ -384,11 +387,14 @@ interface ReservationTombstone { } interface ClaimedAdmission { + readonly attempt: ReservationAttempt; readonly attemptId: string; + readonly expiresAt: number; readonly slot: string; readonly navigationGeneration: object; - readonly renderSource: ReservationRenderSource; - readonly winnerContext: WinnerContext; + active: boolean; + renderSource: ReservationRenderSource | undefined; + winnerContext: WinnerContext | undefined; } type ReservationEntry = LiveReservation | ReservationTombstone; @@ -607,6 +613,18 @@ export function isRendererReservationId(value: unknown): value is string { return typeof value === 'string' && matches(RESERVATION_ID, value); } +/** Whether a candidate is an exact service instance created by this module. */ +export function isReservationService(value: unknown): value is ReservationService { + try { + return ( + ((typeof value === 'object' && value !== null) || typeof value === 'function') && + hasWeakSetValue(reservationServices, value) + ); + } catch { + return false; + } +} + /** Construct the runtime-owned renderer reservation service. */ export function createReservationService(options: ReservationServiceOptions): ReservationService { let nowSource: () => number = defaultNow; @@ -627,10 +645,26 @@ export function createReservationService(options: ReservationServiceOptions): Re const readNow = monotonicClock(nowSource); const entries = new Map(); const ownerRegistrations = new WeakMap(); - const claimAdmissions = new WeakMap(); + const claimAdmissions = new Map(); + + const invalidateClaimAdmission = (claim: object, admission: ClaimedAdmission): void => { + if (mapValue(claimAdmissions, claim) === admission) deleteMapValue(claimAdmissions, claim); + admission.active = false; + admission.renderSource = undefined; + admission.winnerContext = undefined; + }; + + const invalidateClaimAdmissions = (predicate: (admission: ClaimedAdmission) => boolean): void => { + const snapshot = entrySnapshot(claimAdmissions); + for (let index = 0; index < snapshot.length; index += 1) { + const pair = snapshot[index]; + if (pair && predicate(pair[1])) invalidateClaimAdmission(pair[0], pair[1]); + } + }; const disposeStore = (): void => { disposed = true; + invalidateClaimAdmissions(() => true); const snapshot = entrySnapshot(entries); for (let index = 0; index < snapshot.length; index += 1) { const pair = snapshot[index]; @@ -639,6 +673,7 @@ export function createReservationService(options: ReservationServiceOptions): Re }; const prune = (now: number): void => { + invalidateClaimAdmissions((admission) => admission.expiresAt <= now); const snapshot = entrySnapshot(entries); for (let index = 0; index < snapshot.length; index += 1) { const pair = snapshot[index]; @@ -748,6 +783,7 @@ export function createReservationService(options: ReservationServiceOptions): Re const disposeOwnerEntries = (generation: object, state: OwnerCallbackState): void => { if (!state.active || state.disposed) return; state.disposed = true; + invalidateClaimAdmissions((admission) => admission.navigationGeneration === generation); const snapshot = entrySnapshot(entries); for (let index = 0; index < snapshot.length; index += 1) { const pair = snapshot[index]; @@ -1101,8 +1137,6 @@ export function createReservationService(options: ReservationServiceOptions): Re const result = frozenResult({ recognized: true as const, claimed: true as const, - renderSource: entry.renderSource, - winnerContext: entry.winnerContext, pucSource: fields.pucSource, expiresAt: entry.expiresAt, }); @@ -1124,13 +1158,20 @@ export function createReservationService(options: ReservationServiceOptions): Re return refusedClaim(replacement?.state ?? 'stale'); } try { - setWeakMapValue(claimAdmissions, result, { + const claimedAdmission: ClaimedAdmission = { + attempt, attemptId: identity.id, + expiresAt: entry.expiresAt, slot: identity.slot, navigationGeneration: entry.navigationGeneration, + active: true, renderSource: entry.renderSource, winnerContext: entry.winnerContext, - }); + }; + setMapValue(claimAdmissions, result, claimedAdmission); + if (mapValue(claimAdmissions, result) !== claimedAdmission) { + throw new Error('claim admission publication failed'); + } } catch { storeFaulted = true; rollbackWinnerAdmission(admission); @@ -1148,7 +1189,31 @@ export function createReservationService(options: ReservationServiceOptions): Re if (!fields || (typeof claim !== 'object' && typeof claim !== 'function') || claim === null) { return undefined; } - const admission = weakMapValue(claimAdmissions, claim); + const admission = mapValue(claimAdmissions, claim); + const now = clock(); + const currentIdentity = admission ? attemptIdentity(admission.attempt) : undefined; + let currentContext: WinnerContext | undefined; + try { + currentContext = admission?.attempt.winnerContext; + } catch { + currentContext = undefined; + } + if ( + admission && + (!admission.active || + !admission.renderSource || + !admission.winnerContext || + now === undefined || + now >= admission.expiresAt || + !currentAttempt(admission.attempt) || + !currentIdentity || + currentIdentity.id !== admission.attemptId || + currentIdentity.slot !== admission.slot || + currentContext !== admission.winnerContext) + ) { + invalidateClaimAdmission(claim, admission); + return undefined; + } if ( !admission || fields.attemptId !== admission.attemptId || @@ -1158,11 +1223,12 @@ export function createReservationService(options: ReservationServiceOptions): Re ) { return undefined; } - if (!deleteWeakMapValue(claimAdmissions, claim)) return undefined; - return frozenResult({ - renderSource: admission.renderSource, - winnerContext: admission.winnerContext, - }); + const renderSource = admission.renderSource; + const winnerContext = admission.winnerContext; + invalidateClaimAdmission(claim, admission); + return renderSource && winnerContext + ? frozenResult({ renderSource, winnerContext }) + : undefined; }, recognize, tombstone(input, state): boolean { @@ -1271,5 +1337,6 @@ export function createReservationService(options: ReservationServiceOptions): Re }); }, }; + addWeakSetValue(reservationServices, service); return frozenResult(service); } diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index c3e0e89e8..becae5367 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { createRuntimeSession } from '../../src/kernel/sessions'; import type { RenderAttemptScope, WinnerContext } from '../../src/kernel/sessions'; import { createCommittedArtifactStore, @@ -7,11 +9,16 @@ import { createSlotOperation, type CommittedRenderArtifact, type RenderAttempt, - type RenderAttemptOptions, 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'; @@ -44,23 +51,14 @@ function prepareRenderSource(candidate: unknown) { return undefined; } -const claimedSources = new WeakMap(); +const RESERVATION_ID = 'r1_0000000000000000000000'; +const attemptReservations = new WeakMap(); +const matrixClaims = new WeakMap(); -function claimed(source: typeof ADM_SOURCE | typeof APS_SOURCE): object { - const claim = Object.freeze({}); - claimedSources.set(claim, source); - return claim; +function reservations(): ReservationService { + return createReservationService({ now: () => 0, prepareRenderSource }); } -const consumeClaimedWinner: RenderAttemptOptions['consumeClaimedWinner'] = (claim, expectation) => { - if ((typeof claim !== 'object' && typeof claim !== 'function') || claim === null) { - return undefined; - } - const source = claimedSources.get(claim); - if (!source || !claimedSources.delete(claim)) return undefined; - return Object.freeze({ renderSource: source, winnerContext: expectation.winnerContext }); -}; - type TestOwner = RenderAttemptScope & { admitClaimedContext(context: WinnerContext): void; disposeFromNavigation(): void; @@ -154,19 +152,52 @@ function attempt( scope = owner(), options: Partial[0]> = {} ): RenderAttempt { + const reservationService = options.reservations ?? reservations(); const result = createRenderAttempt({ artifacts: options.artifacts ?? createCommittedArtifactStore(), - consumeClaimedWinner: options.consumeClaimedWinner ?? consumeClaimedWinner, owner: scope, prepareRenderSource: options.prepareRenderSource ?? prepareRenderSource, + reservations: reservationService, ...(options.parentAttemptId === undefined ? {} : { parentAttemptId: options.parentAttemptId }), ...(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 claimed( + render: RenderAttempt, + scope: TestOwner, + source: ReservationRenderSource +): Extract { + const service = attemptReservations.get(render); + if (!service) throw new Error('should own a reservation service'); + const registered = service.registerRender({ + reservationId: RESERVATION_ID, + slot: scope.slot, + navigation: { + generation: scope.navigationGeneration, + isCurrent: scope.isCurrent, + onDispose: scope.onDispose, + }, + attemptId: scope.id, + renderSource: source, + winnerContext: WINNER_CONTEXT, + }); + if (!registered.ok) throw new Error('should register a render reservation'); + const result = service.claim({ + reservationId: RESERVATION_ID, + slot: scope.slot, + navigationGeneration: scope.navigationGeneration, + attempt: scope, + pucSource: Object.freeze({}), + }); + if (!result.recognized || !result.claimed) throw new Error('should claim a reservation'); + return result; +} + function slotOperation(options: SlotOperationOptions): SlotOperation { const result = createSlotOperation(options); expect(result).toMatchObject({ ok: true }); @@ -183,8 +214,7 @@ describe('RenderAttempt state machine', () => { expect(render.beginGamClaim()).toBe(true); expect(render.beginDirect()).toBe(false); - scope.admitClaimedContext(WINNER_CONTEXT); - expect(render.admitClaimedWinner(claimed(APS_SOURCE))).toBe(true); + 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); @@ -226,8 +256,7 @@ describe('RenderAttempt state machine', () => { const pucArtifact = artifact(pucOwner, 'puc'); const puc = attempt(pucOwner); expect(puc.beginGamClaim()).toBe(true); - pucOwner.admitClaimedContext(WINNER_CONTEXT); - expect(puc.admitClaimedWinner(claimed(ADM_SOURCE))).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); @@ -262,8 +291,7 @@ describe('RenderAttempt state machine', () => { const pucApsOwner = owner('a1_0000000000000000000002'); const pucAps = attempt(pucApsOwner); expect(pucAps.beginGamClaim()).toBe(true); - pucApsOwner.admitClaimedContext(WINNER_CONTEXT); - expect(pucAps.admitClaimedWinner(claimed(APS_SOURCE))).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); @@ -275,27 +303,23 @@ describe('RenderAttempt state machine', () => { const scope = owner(); const render = attempt(scope); expect(render.beginGamClaim()).toBe(true); - scope.admitClaimedContext(WINNER_CONTEXT); - const exactClaim = claimed(APS_SOURCE); - expect(render.admitClaimedWinner(Object.freeze({}))).toBe(false); - expect(render.admitClaimedWinner(exactClaim)).toBe(true); - expect(render.renderSource).toBe(APS_SOURCE); - expect(render.winnerContext).toBe(WINNER_CONTEXT); - expect(render.admitClaimedWinner(exactClaim)).toBe(false); + 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, { - consumeClaimedWinner: (_claim, expectation) => - Object.freeze({ - renderSource: ADM_SOURCE, - winnerContext: Object.freeze({ selectedCpm: expectation.winnerContext.selectedCpm }), - }), - }); + const mismatched = attempt(mismatchedOwner); expect(mismatched.beginGamClaim()).toBe(true); - mismatchedOwner.admitClaimedContext(WINNER_CONTEXT); - expect(mismatched.admitClaimedWinner(Object.freeze({}))).toBe(false); + 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', () => { @@ -375,8 +399,7 @@ describe('RenderAttempt state machine', () => { const render = attempt(scope); const claim = (source: typeof APS_SOURCE | typeof ADM_SOURCE): void => { render.beginGamClaim(); - scope.admitClaimedContext(WINNER_CONTEXT); - render.admitClaimedWinner(claimed(source)); + render.admitClaimedWinner(claimed(render, scope, source)); }; switch (name) { case 'created': @@ -386,7 +409,7 @@ describe('RenderAttempt state machine', () => { break; case 'waiting_for_gam_and_claim': render.beginGamClaim(); - scope.admitClaimedContext(WINNER_CONTEXT); + matrixClaims.set(render, claimed(render, scope, APS_SOURCE)); break; case 'waiting_for_gam_and_claim_admitted': claim(APS_SOURCE); @@ -454,7 +477,7 @@ describe('RenderAttempt state machine', () => { case 'admit_direct': return render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); case 'admit_claimed': - return render.admitClaimedWinner(claimed(APS_SOURCE)); + return render.admitClaimedWinner(matrixClaims.get(render) ?? Object.freeze({})); case 'begin_gam_claim': return render.beginGamClaim(); case 'owner_claimed': @@ -570,8 +593,7 @@ describe('RenderAttempt state machine', () => { const registrationOwner = owner(); const registration = attempt(registrationOwner); registration.beginGamClaim(); - registrationOwner.admitClaimedContext(WINNER_CONTEXT); - registration.admitClaimedWinner(claimed(APS_SOURCE)); + registration.admitClaimedWinner(claimed(registration, registrationOwner, APS_SOURCE)); registration.ownerClaimed(); vi.advanceTimersByTime(2_999); expect(registration.snapshot().state).toBe('waiting_for_owner'); @@ -584,8 +606,7 @@ describe('RenderAttempt state machine', () => { const insertionOwner = owner(ATTEMPT_TWO); const insertion = attempt(insertionOwner); insertion.beginGamClaim(); - insertionOwner.admitClaimedContext(WINNER_CONTEXT); - insertion.admitClaimedWinner(claimed(APS_SOURCE)); + insertion.admitClaimedWinner(claimed(insertion, insertionOwner, APS_SOURCE)); insertion.ownerClaimed(); insertion.ownerRegistered(); vi.advanceTimersByTime(1_000); @@ -708,7 +729,7 @@ describe('RenderAttempt state machine', () => { createRenderAttempt({ owner: malformed, artifacts: createCommittedArtifactStore(), - consumeClaimedWinner, + reservations: reservations(), prepareRenderSource, }) ).toEqual({ ok: false, reason: 'invalid_attempt' }); @@ -719,7 +740,7 @@ describe('RenderAttempt state machine', () => { createRenderAttempt({ owner: stale, artifacts: createCommittedArtifactStore(), - consumeClaimedWinner, + reservations: reservations(), prepareRenderSource, }) ).toEqual({ ok: false, reason: 'stale_owner' }); @@ -746,7 +767,7 @@ describe('RenderAttempt state machine', () => { createRenderAttempt({ owner: scope, artifacts: createCommittedArtifactStore(), - consumeClaimedWinner, + reservations: reservations(), prepareRenderSource, }) ).toEqual({ ok: false, reason: 'stale_owner' }); @@ -754,6 +775,73 @@ describe('RenderAttempt state machine', () => { } }); + 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); @@ -942,6 +1030,40 @@ describe('committed artifact ownership', () => { 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()); @@ -1091,7 +1213,7 @@ describe('SlotOperation result isolation', () => { const result = createRenderAttempt({ owner: childOwner, artifacts: createCommittedArtifactStore(), - consumeClaimedWinner, + reservations: reservations(), prepareRenderSource, parentAttemptId, }); @@ -1164,7 +1286,7 @@ describe('SlotOperation result isolation', () => { const result = createRenderAttempt({ owner: owner(ATTEMPT_TWO), artifacts: createCommittedArtifactStore(), - consumeClaimedWinner, + reservations: reservations(), prepareRenderSource, parentAttemptId, }); diff --git a/crates/trusted-server-js/lib/test/services/reservations.test.ts b/crates/trusted-server-js/lib/test/services/reservations.test.ts index ced9d7746..dd0df9c18 100644 --- a/crates/trusted-server-js/lib/test/services/reservations.test.ts +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -193,10 +193,18 @@ describe('renderer reservation identity and registration', () => { 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'); - expect(result.renderSource).toEqual(source); - expect(result.renderSource).not.toBe(mutable); - expect(Object.isFrozen(result.renderSource)).toBe(true); - expect(Object.isFrozen(result.winnerContext)).toBe(true); + const context = attempt.winnerContext; + if (!context) throw new Error('Expected an admitted winner context'); + const admission = service.consumeClaim(result, { + 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); } }); @@ -207,6 +215,14 @@ describe('renderer reservation identity and registration', () => { 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'); @@ -242,10 +258,9 @@ describe('renderer reservation identity and registration', () => { winnerContext: context, }); expect(admission).toEqual({ - renderSource: result.renderSource, + renderSource: admSource(), winnerContext: context, }); - expect(admission?.renderSource).toBe(result.renderSource); expect(admission?.winnerContext).toBe(context); expect(Object.isFrozen(admission)).toBe(true); expect( @@ -258,6 +273,34 @@ describe('renderer reservation identity and registration', () => { ).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, { + 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); diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index ad190b342..35e658f1b 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -1383,7 +1383,10 @@ Every task's regression suite therefore remains green in task order. and disposer. Direct winner admission constructs the context from the exact validated joined server winner; a PUC claim receives the same context from the consumed reservation by presenting the exact one-shot frozen claim result; - source and context are never accepted as independently swappable values. + the result exposes neither source nor context, and those values are never + accepted as independently swappable inputs. Accept that claim only through the + branded reservation service while the service, + original attempt/navigation, and fixed reservation expiry remain live. Obtain the id only from the NavigationSession issuer before registering work; an issuance failure settles `identity_generation_failed` without DOM/global mutation. Add `SlotOperation` above attempts so a primary and optional fallback @@ -1400,6 +1403,9 @@ Every task's regression suite therefore remains green in task order. Require synchronous exact-once artifact disposal, reject Promise/thenable disposers, reject republication once disposal starts, and require private provenance for artifact stores, attempts, and fallback children. + On every post-issuance construction rejection, best-effort dispose the exact + captured attempt scope and prove the session permits an immediate same-slot + retry. - [ ] **Step 3: Implement direct APS:** - validate descriptor before DOM mutation; diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index f85d659cc..beb692f7d 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -652,7 +652,12 @@ rather than reconstructing it from Prebid. A successful PUC claim transfers the context into the `RenderAttempt` before replacing the live entry with a tombstone. The exact frozen successful claim result is also a one-shot internal capability: `RenderAttempt` consumes that object to receive its already-bound render source and -winner context. Callers cannot pass, reconstruct, or swap either field separately. +winner context from the service's bounded internal record. The claim object exposes +neither field, so callers cannot pass, reconstruct, or swap them separately. +Consumption additionally requires the branded reservation service, its live runtime, +the original current attempt and navigation generation, and a time strictly before +the reservation's fixed expiry. Disposal, expiry, or loss of exact attempt authority +invalidates the claim capability. The tombstone discards the render source and winner context and retains only the id, original expiry, terminal state, and minimum suppression metadata. Neither the renderer descriptor nor any capability crossing a browser-context boundary contains @@ -1396,6 +1401,9 @@ whose deadline they enforce and are cleared by the transition that settles them. `waiting_for_document` accepts only an APS source; `waiting_for_adm` accepts only an ADM or cache source. A direct path stages only a `direct_iframe` artifact, while an owner-controlled PUC path stages only a `puc` artifact. +Construction owns an already-issued attempt scope: every rejection after scope +issuance best-effort disposes that exact scope so session indexes cannot retain a +failed construction or block a same-slot retry. An accepted transition first atomically promotes durable DOM/targeting ownership from the attempt into one `CommittedRenderArtifact` owned by the exact slot and From fad69a0812681db9c7a503bf81a05910da3037e2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:29:29 -0700 Subject: [PATCH 042/194] Bind claims to exact render attempts --- .../lib/src/services/render.ts | 1 + .../lib/src/services/reservations.ts | 3 ++ .../lib/test/services/reservations.test.ts | 28 +++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index b9ce1c31e..31996da1b 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -992,6 +992,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp admission = Reflect.apply(consumeClaimMethod, reservations, [ candidate, frozen({ + attempt: owner, attemptId: id, slot, navigationGeneration, diff --git a/crates/trusted-server-js/lib/src/services/reservations.ts b/crates/trusted-server-js/lib/src/services/reservations.ts index feabb9ee8..49ba225f7 100644 --- a/crates/trusted-server-js/lib/src/services/reservations.ts +++ b/crates/trusted-server-js/lib/src/services/reservations.ts @@ -316,6 +316,7 @@ export type ReservationClaimResult = /** Exact lifecycle authority required to consume one successful claim object. */ export interface ReservationClaimExpectation { + readonly attempt: ReservationAttempt; readonly attemptId: string; readonly slot: string; readonly navigationGeneration: object; @@ -1181,6 +1182,7 @@ export function createReservationService(options: ReservationServiceOptions): Re }, consumeClaim(claim, expectation): ReservationClaimAdmission | undefined { const fields = ownDataRecord(expectation, [ + 'attempt', 'attemptId', 'slot', 'navigationGeneration', @@ -1216,6 +1218,7 @@ export function createReservationService(options: ReservationServiceOptions): Re } if ( !admission || + fields.attempt !== admission.attempt || fields.attemptId !== admission.attemptId || fields.slot !== admission.slot || fields.navigationGeneration !== admission.navigationGeneration || diff --git a/crates/trusted-server-js/lib/test/services/reservations.test.ts b/crates/trusted-server-js/lib/test/services/reservations.test.ts index dd0df9c18..fa31fc3e9 100644 --- a/crates/trusted-server-js/lib/test/services/reservations.test.ts +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -196,6 +196,7 @@ describe('renderer reservation identity and registration', () => { 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, @@ -226,8 +227,30 @@ describe('renderer reservation identity and registration', () => { 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({}), @@ -236,6 +259,7 @@ describe('renderer reservation identity and registration', () => { ).toBeUndefined(); expect( service.consumeClaim(result, { + attempt, attemptId: attempt.id, slot: attempt.slot, navigationGeneration: navigation.generation, @@ -244,6 +268,7 @@ describe('renderer reservation identity and registration', () => { ).toBeUndefined(); expect( service.consumeClaim(Object.freeze({ ...result }), { + attempt, attemptId: attempt.id, slot: attempt.slot, navigationGeneration: navigation.generation, @@ -252,6 +277,7 @@ describe('renderer reservation identity and registration', () => { ).toBeUndefined(); const admission = service.consumeClaim(result, { + attempt, attemptId: attempt.id, slot: attempt.slot, navigationGeneration: navigation.generation, @@ -265,6 +291,7 @@ describe('renderer reservation identity and registration', () => { expect(Object.isFrozen(admission)).toBe(true); expect( service.consumeClaim(result, { + attempt, attemptId: attempt.id, slot: attempt.slot, navigationGeneration: navigation.generation, @@ -292,6 +319,7 @@ describe('renderer reservation identity and registration', () => { expect( service.consumeClaim(result, { + attempt, attemptId: attempt.id, slot: attempt.slot, navigationGeneration: navigation.generation, From 749ef997380aeb92eb5ec14bdd062866a3f9804a Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:37:49 -0700 Subject: [PATCH 043/194] Add renderer nonce registry --- .../lib/src/services/render.ts | 489 +++++++++++++- .../lib/test/services/render.test.ts | 618 ++++++++++++++++++ 2 files changed, 1093 insertions(+), 14 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index 31996da1b..9d848bc2f 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -1,4 +1,6 @@ import type { RenderAttemptScope, WinnerContext } from '../kernel/sessions'; +import { mintBrowserRendererNonce } from '../kernel/identity'; +import type { IdentityGenerationResult } from '../kernel/identity'; import { isReservationService } from './reservations'; import type { @@ -9,14 +11,23 @@ import type { } from './reservations'; const ATTEMPT_ID = /^a1_[A-Za-z0-9_-]{22}$/; +const RENDERER_NONCE = /^n1_[A-Za-z0-9_-]{22}$/; +const MAX_RENDERER_NONCES = 256; +const MAX_RENDERER_NONCE_DRAWS = 8; const objectFreezeIntrinsic = Object.freeze; const arrayIncludesIntrinsic = Array.prototype.includes; +const arrayPushIntrinsic = Array.prototype.push; +const arraySliceIntrinsic = Array.prototype.slice; +const arraySpliceIntrinsic = Array.prototype.splice; const mapGetIntrinsic = Map.prototype.get; const mapSetIntrinsic = Map.prototype.set; const mapDeleteIntrinsic = Map.prototype.delete; const mapClearIntrinsic = Map.prototype.clear; const mapEntriesIntrinsic = Map.prototype.entries; const mapValuesIntrinsic = Map.prototype.values; +const mapSizeGetter = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.get as ( + this: Map +) => number; const mapEntryIteratorNextIntrinsic = Object.getPrototypeOf(new Map().entries()).next as ( this: IterableIterator ) => IteratorResult; @@ -28,14 +39,19 @@ const setHasIntrinsic = Set.prototype.has; const setDeleteIntrinsic = Set.prototype.delete; const setClearIntrinsic = Set.prototype.clear; const setValuesIntrinsic = Set.prototype.values; +const setSizeGetter = Object.getOwnPropertyDescriptor(Set.prototype, 'size')?.get as ( + this: Set +) => number; const setValueIteratorNextIntrinsic = Object.getPrototypeOf(new Set().values()).next as ( this: IterableIterator ) => IteratorResult; const weakMapGetIntrinsic = WeakMap.prototype.get; const weakMapSetIntrinsic = WeakMap.prototype.set; +const weakMapDeleteIntrinsic = WeakMap.prototype.delete; const weakMapHasIntrinsic = WeakMap.prototype.has; const weakSetAddIntrinsic = WeakSet.prototype.add; const weakSetHasIntrinsic = WeakSet.prototype.has; +const weakSetDeleteIntrinsic = WeakSet.prototype.delete; const promiseThenIntrinsic = Promise.prototype.then; const artifactDisposals = new WeakMap(); const committedArtifactStores = new WeakSet(); @@ -46,6 +62,18 @@ function frozen(value: Value): Readonly { return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; } +function arrayPush(array: Value[], value: Value): number { + return Reflect.apply(arrayPushIntrinsic, array, [value]) as number; +} + +function arraySlice(array: Value[]): Value[] { + return Reflect.apply(arraySliceIntrinsic, array, [0]) as Value[]; +} + +function arraySpliceAll(array: Value[]): Value[] { + return Reflect.apply(arraySpliceIntrinsic, array, [0, array.length]) as Value[]; +} + function mapGet(map: Map, key: Key): Value | undefined { return Reflect.apply(mapGetIntrinsic, map, [key]) as Value | undefined; } @@ -62,6 +90,10 @@ function mapClear(map: Map): void { Reflect.apply(mapClearIntrinsic, map, []); } +function mapSize(map: Map): number { + return Reflect.apply(mapSizeGetter, map, []) as number; +} + function mapEntrySnapshot(map: Map): Array<[Key, Value]> { const iterator = Reflect.apply(mapEntriesIntrinsic, map, []) as IterableIterator<[Key, Value]>; const output: Array<[Key, Value]> = []; @@ -104,6 +136,10 @@ function setClear(set: Set): void { Reflect.apply(setClearIntrinsic, set, []); } +function setSize(set: Set): number { + return Reflect.apply(setSizeGetter, set, []) as number; +} + function setValueSnapshot(set: Set): Value[] { const iterator = Reflect.apply(setValuesIntrinsic, set, []) as IterableIterator; const output: Value[] = []; @@ -133,6 +169,10 @@ function weakMapSet( Reflect.apply(weakMapSetIntrinsic, map, [key, value]); } +function weakMapDelete(map: WeakMap, key: Key): boolean { + return Reflect.apply(weakMapDeleteIntrinsic, map, [key]) as boolean; +} + function weakMapHas(map: WeakMap, key: Key): boolean { return Reflect.apply(weakMapHasIntrinsic, map, [key]) as boolean; } @@ -145,6 +185,10 @@ function weakSetHas(set: WeakSet, value: Value): bo return Reflect.apply(weakSetHasIntrinsic, set, [value]) as boolean; } +function weakSetDelete(set: WeakSet, value: Value): boolean { + return Reflect.apply(weakSetDeleteIntrinsic, set, [value]) as boolean; +} + export const RENDER_FAILURE_REASONS = frozen([ 'auction_timeout', 'auction_disabled', @@ -318,6 +362,47 @@ export interface RenderAttempt { readonly snapshot: () => RenderAttemptSnapshot; } +/** Retained endpoint whose lifetime is owned by one renderer nonce binding. */ +export interface RendererNoncePort { + readonly close: () => void; +} + +export interface RendererNonceIssueInput { + readonly attempt: RenderAttempt; + readonly source: object; + readonly port: RendererNoncePort; +} + +export interface RendererNonceExpectation extends RendererNonceIssueInput { + readonly nonce: string; + readonly generation: object; +} + +export type RendererNonceIssueResult = + | Readonly<{ ok: true; nonce: string }> + | Readonly<{ + ok: false; + reason: 'capability_registry_full' | 'identity_generation_failed' | 'invalid_attempt'; + }>; + +export interface RendererNonceRegistrySnapshot { + readonly bindings: number; + readonly disposed: boolean; + readonly liveNonces: number; +} + +export interface RendererNonceRegistry { + /** On failure the caller retains port ownership; success transfers it to this registry. */ + readonly issue: (input: RendererNonceIssueInput) => RendererNonceIssueResult; + readonly consume: (expectation: RendererNonceExpectation) => boolean; + readonly dispose: () => void; + readonly snapshotForTest: () => RendererNonceRegistrySnapshot; +} + +export interface RendererNonceRegistryOptions { + readonly mintNonce?: () => IdentityGenerationResult; +} + export interface SlotOperationResult { readonly path: 'primary' | 'fallback'; readonly outcome: RenderOutcome; @@ -423,7 +508,9 @@ function validArtifact( ) { return false; } - for (const name of names) { + for (let index = 0; index < names.length; index += 1) { + const name = names[index]; + if (!name) return false; const descriptor = Object.getOwnPropertyDescriptor(value, name); if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) return false; } @@ -508,7 +595,11 @@ export function createCommittedArtifactStore(): CommittedArtifactStore { const disposeGeneration = (navigationGeneration: object): void => { const snapshot = mapEntrySnapshot(entries); - for (const [slot, artifact] of snapshot) { + for (let index = 0; index < snapshot.length; index += 1) { + const entry = snapshot[index]; + if (!entry) continue; + const slot = entry[0]; + const artifact = entry[1]; if ( artifact.navigationGeneration === navigationGeneration && mapGet(entries, slot) === artifact @@ -527,7 +618,9 @@ export function createCommittedArtifactStore(): CommittedArtifactStore { mapClear(entries); disposed = true; setClear(pendingNavigationDisposals); - for (const artifact of snapshot) disposeArtifact(artifact); + for (let index = 0; index < snapshot.length; index += 1) { + disposeArtifact(snapshot[index]); + } return; } const generations = setValueSnapshot(pendingNavigationDisposals); @@ -535,7 +628,10 @@ export function createCommittedArtifactStore(): CommittedArtifactStore { if (generations.length === 0) return; mutating = true; try { - for (const generation of generations) disposeGeneration(generation); + for (let index = 0; index < generations.length; index += 1) { + const generation = generations[index]; + if (generation) disposeGeneration(generation); + } } finally { mutating = false; if (disposeRequested || setValueSnapshot(pendingNavigationDisposals).length > 0) { @@ -662,7 +758,9 @@ export function createCommittedArtifactStore(): CommittedArtifactStore { disposeRequested = false; disposed = true; setClear(pendingNavigationDisposals); - for (const artifact of snapshot) disposeArtifact(artifact); + for (let index = 0; index < snapshot.length; index += 1) { + disposeArtifact(snapshot[index]); + } } catch { disposeRequested = false; disposed = true; @@ -1022,8 +1120,10 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp }; const notify = (terminal: RenderOutcome): void => { - const snapshot = observers.splice(0, observers.length); - for (const observer of snapshot) { + const snapshot = arraySpliceAll(observers); + for (let index = 0; index < snapshot.length; index += 1) { + const observer = snapshot[index]; + if (!observer) continue; try { observer(terminal); } catch { @@ -1036,7 +1136,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp if (outcome !== undefined) return false; outcome = terminal; state = terminalState(terminal); - history.push(state); + arrayPush(history, state); clearDeadline(); admittedRenderSource = undefined; admittedWinnerContext = undefined; @@ -1101,7 +1201,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp return false; } state = next; - history.push(next); + arrayPush(history, next); clearDeadline(); if (outcome === undefined) armDeadline(next); return true; @@ -1252,12 +1352,12 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp } return true; } - observers.push(callback); + arrayPush(observers, callback); return true; }, snapshot: () => frozen({ - history: frozen(history.slice()), + history: frozen(arraySlice(history)), outcome, state, }), @@ -1293,6 +1393,365 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp return frozen({ ok: true, value: frozen(lifecycle) }); } +interface RendererNonceBinding { + readonly nonce: string; + readonly attempt: RenderAttempt; + readonly attemptId: string; + readonly generation: object; + readonly source: object; + readonly port: RendererNoncePort; + readonly closeMethod: RendererNoncePort['close']; + consumed: boolean; + closed: boolean; +} + +function validRendererNonce(value: unknown): value is string { + return typeof value === 'string' && RENDERER_NONCE.test(value); +} + +function readMintedRendererNonce(value: unknown): string | undefined { + try { + if (typeof value !== 'object' || value === null || !Object.isFrozen(value)) return undefined; + if (Object.getPrototypeOf(value) !== Object.prototype) return undefined; + if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const names = Object.getOwnPropertyNames(value).sort(); + const ok = Object.getOwnPropertyDescriptor(value, 'ok'); + if (!ok || !ok.enumerable || !('value' in ok)) return undefined; + if (ok.value === true) { + if (names.length !== 2 || names[0] !== 'ok' || names[1] !== 'value') return undefined; + const nonce = Object.getOwnPropertyDescriptor(value, 'value'); + return nonce && nonce.enumerable && 'value' in nonce && validRendererNonce(nonce.value) + ? nonce.value + : undefined; + } + if (ok.value === false && names.length === 2 && names[0] === 'ok' && names[1] === 'reason') { + const reason = Object.getOwnPropertyDescriptor(value, 'reason'); + if ( + reason && + reason.enumerable && + 'value' in reason && + reason.value === 'identity_generation_failed' + ) { + return undefined; + } + } + return undefined; + } catch { + return undefined; + } +} + +function readRendererNonceExpectation(value: unknown): RendererNonceExpectation | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return undefined; + } + if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const names = Object.getOwnPropertyNames(value).sort(); + const expected = ['attempt', 'generation', 'nonce', 'port', 'source']; + if (names.length !== expected.length) return undefined; + const fields: Record = Object.create(null) as Record; + for (let index = 0; index < expected.length; index += 1) { + const name = expected[index]; + if (!name || names[index] !== name) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + fields[name] = descriptor.value; + } + return fields as unknown as RendererNonceExpectation; + } catch { + return undefined; + } +} + +/** Own the bounded, one-use capabilities for APS renderer-document acceptance. */ +export function createRendererNonceRegistry( + options: RendererNonceRegistryOptions = {} +): RendererNonceRegistry { + const liveByNonce = new Map(); + const bindingByAttempt = new WeakMap(); + const bindingByGeneration = new WeakMap(); + const bindingByPort = new WeakMap(); + const bindings = new Set(); + const pendingNonces = new Set(); + const pendingAttempts = new WeakSet(); + const pendingGenerations = new WeakSet(); + const pendingPorts = new WeakSet(); + const retiredPorts = new WeakSet(); + let pendingCount = 0; + let disposed = false; + let mintNonce: () => IdentityGenerationResult; + try { + mintNonce = options.mintNonce ?? mintBrowserRendererNonce; + } catch { + mintNonce = () => frozen({ ok: false, reason: 'identity_generation_failed' }); + } + + const closeBinding = (binding: RendererNonceBinding): void => { + if (binding.closed) return; + binding.closed = true; + binding.consumed = true; + if (mapGet(liveByNonce, binding.nonce) === binding) { + mapDelete(liveByNonce, binding.nonce); + } + if (weakMapGet(bindingByAttempt, binding.attempt) === binding) { + weakMapDelete(bindingByAttempt, binding.attempt); + } + if (weakMapGet(bindingByGeneration, binding.generation) === binding) { + weakMapDelete(bindingByGeneration, binding.generation); + } + weakSetAdd(retiredPorts, binding.port); + if (weakMapGet(bindingByPort, binding.port) === binding) { + weakMapDelete(bindingByPort, binding.port); + } + setDelete(bindings, binding); + try { + Reflect.apply(binding.closeMethod, binding.port, []); + } catch { + // Closing a retained browser port is best-effort and exact-once. + } + }; + + const rollbackProvisionalBinding = (binding: RendererNonceBinding): void => { + binding.closed = true; + binding.consumed = true; + if (mapGet(liveByNonce, binding.nonce) === binding) { + mapDelete(liveByNonce, binding.nonce); + } + if (weakMapGet(bindingByAttempt, binding.attempt) === binding) { + weakMapDelete(bindingByAttempt, binding.attempt); + } + if (weakMapGet(bindingByGeneration, binding.generation) === binding) { + weakMapDelete(bindingByGeneration, binding.generation); + } + if (weakMapGet(bindingByPort, binding.port) === binding) { + weakMapDelete(bindingByPort, binding.port); + } + setDelete(bindings, binding); + }; + + const invalidIssue = ( + reason: Exclude['reason'] + ): RendererNonceIssueResult => frozen({ ok: false, reason }); + + const registry: RendererNonceRegistry = { + issue(input): RendererNonceIssueResult { + let attempt: RenderAttempt; + let source: object; + let port: RendererNoncePort; + let closeMethod: RendererNoncePort['close']; + let attemptId: string; + let generation: object; + let onSettledMethod: RenderAttempt['onSettled']; + let snapshotMethod: RenderAttempt['snapshot']; + try { + attempt = input.attempt; + source = input.source; + port = input.port; + closeMethod = port.close; + attemptId = attempt.id; + generation = attempt.generation; + onSettledMethod = attempt.onSettled; + snapshotMethod = attempt.snapshot; + if ( + disposed || + !weakSetHas(renderAttempts, attempt) || + (typeof source !== 'object' && typeof source !== 'function') || + source === null || + (typeof port !== 'object' && typeof port !== 'function') || + port === null || + typeof closeMethod !== 'function' || + !validAttemptId(attemptId) || + (typeof generation !== 'object' && typeof generation !== 'function') || + generation === null || + typeof onSettledMethod !== 'function' || + typeof snapshotMethod !== 'function' || + Reflect.apply(snapshotMethod, attempt, []).outcome !== undefined || + weakMapHas(bindingByAttempt, attempt) || + weakSetHas(pendingAttempts, attempt) || + weakMapHas(bindingByGeneration, generation) || + weakSetHas(pendingGenerations, generation) || + weakMapHas(bindingByPort, port) || + weakSetHas(pendingPorts, port) || + weakSetHas(retiredPorts, port) + ) { + return invalidIssue('invalid_attempt'); + } + } catch { + return invalidIssue('invalid_attempt'); + } + if (setSize(bindings) + pendingCount >= MAX_RENDERER_NONCES) { + return invalidIssue('capability_registry_full'); + } + try { + weakSetAdd(pendingAttempts, attempt); + weakSetAdd(pendingGenerations, generation); + weakSetAdd(pendingPorts, port); + pendingCount += 1; + } catch { + weakSetDelete(pendingAttempts, attempt); + weakSetDelete(pendingGenerations, generation); + weakSetDelete(pendingPorts, port); + return invalidIssue('invalid_attempt'); + } + + const attemptStillIssuable = (): boolean => { + try { + return ( + !disposed && + !weakMapHas(bindingByAttempt, attempt) && + !weakMapHas(bindingByGeneration, generation) && + !weakMapHas(bindingByPort, port) && + !weakSetHas(retiredPorts, port) && + attempt.id === attemptId && + attempt.generation === generation && + Reflect.apply(snapshotMethod, attempt, []).outcome === undefined + ); + } catch { + return false; + } + }; + + let nonce: string | undefined; + try { + for (let draw = 0; draw < MAX_RENDERER_NONCE_DRAWS; draw += 1) { + let minted: unknown; + try { + minted = Reflect.apply(mintNonce, undefined, []); + } catch { + return invalidIssue('identity_generation_failed'); + } + const candidate = readMintedRendererNonce(minted); + if (!attemptStillIssuable()) return invalidIssue('invalid_attempt'); + if (setSize(bindings) + pendingCount > MAX_RENDERER_NONCES) { + return invalidIssue('capability_registry_full'); + } + if (!candidate) return invalidIssue('identity_generation_failed'); + if (!mapGet(liveByNonce, candidate) && !setHas(pendingNonces, candidate)) { + nonce = candidate; + setAdd(pendingNonces, candidate); + break; + } + } + if (!nonce) return invalidIssue('identity_generation_failed'); + if (!attemptStillIssuable()) return invalidIssue('invalid_attempt'); + + const binding: RendererNonceBinding = { + nonce, + attempt, + attemptId, + generation, + source, + port, + closeMethod, + consumed: false, + closed: false, + }; + let committed = false; + try { + const registered = Reflect.apply(onSettledMethod, attempt, [ + () => { + if (committed) closeBinding(binding); + }, + ]); + if ( + registered !== true || + !attemptStillIssuable() || + setSize(bindings) + pendingCount > MAX_RENDERER_NONCES || + mapGet(liveByNonce, nonce) !== undefined || + !setHas(pendingNonces, nonce) + ) { + return invalidIssue('invalid_attempt'); + } + mapSet(liveByNonce, nonce, binding); + weakMapSet(bindingByAttempt, attempt, binding); + weakMapSet(bindingByGeneration, generation, binding); + weakMapSet(bindingByPort, port, binding); + setAdd(bindings, binding); + if ( + mapGet(liveByNonce, nonce) !== binding || + weakMapGet(bindingByAttempt, attempt) !== binding || + weakMapGet(bindingByGeneration, generation) !== binding || + weakMapGet(bindingByPort, port) !== binding || + !setHas(bindings, binding) + ) { + rollbackProvisionalBinding(binding); + return invalidIssue('invalid_attempt'); + } + committed = true; + } catch { + rollbackProvisionalBinding(binding); + return invalidIssue('invalid_attempt'); + } + return frozen({ ok: true, nonce }); + } finally { + if (nonce) setDelete(pendingNonces, nonce); + weakSetDelete(pendingAttempts, attempt); + weakSetDelete(pendingGenerations, generation); + weakSetDelete(pendingPorts, port); + pendingCount -= 1; + } + }, + consume(expectation): boolean { + try { + const fields = readRendererNonceExpectation(expectation); + if (disposed || !fields || !validRendererNonce(fields.nonce)) return false; + const binding = mapGet(liveByNonce, fields.nonce); + if ( + !binding || + binding.closed || + binding.consumed || + !setHas(bindings, binding) || + fields.attempt !== binding.attempt || + fields.generation !== binding.generation || + fields.source !== binding.source || + fields.port !== binding.port || + weakMapGet(bindingByPort, binding.port) !== binding || + binding.attempt.id !== binding.attemptId || + binding.attempt.generation !== binding.generation || + Reflect.apply(binding.attempt.snapshot, binding.attempt, []).outcome !== undefined + ) { + return false; + } + if ( + disposed || + binding.closed || + binding.consumed || + mapGet(liveByNonce, binding.nonce) !== binding || + !mapDelete(liveByNonce, binding.nonce) + ) { + return false; + } + binding.consumed = true; + return true; + } catch { + return false; + } + }, + dispose(): void { + if (disposed) return; + disposed = true; + const snapshot = setValueSnapshot(bindings); + mapClear(liveByNonce); + for (let index = 0; index < snapshot.length; index += 1) { + const binding = snapshot[index]; + if (binding) closeBinding(binding); + } + }, + snapshotForTest: () => + frozen({ + bindings: setSize(bindings), + disposed, + liveNonces: mapSize(liveByNonce), + }), + }; + return frozen(registry); +} + /** Own one public per-slot result without overwriting either child attempt result. */ export function createSlotOperation(options: SlotOperationOptions): SlotOperationCreationResult { const observers: Array<(result: SlotOperationResult) => void> = []; @@ -1333,8 +1792,10 @@ export function createSlotOperation(options: SlotOperationOptions): SlotOperatio const settle = (terminal: SlotOperationResult): boolean => { if (result) return false; result = frozen(terminal); - const snapshot = observers.splice(0, observers.length); - for (const observer of snapshot) { + const snapshot = arraySpliceAll(observers); + for (let index = 0; index < snapshot.length; index += 1) { + const observer = snapshot[index]; + if (!observer) continue; try { observer(result); } catch { @@ -1559,7 +2020,7 @@ export function createSlotOperation(options: SlotOperationOptions): SlotOperatio // Observation cannot change the public result. } } else { - observers.push(callback); + arrayPush(observers, callback); } return true; }, diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index becae5367..f60c00a2a 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -6,6 +6,7 @@ import type { RenderAttemptScope, WinnerContext } from '../../src/kernel/session import { createCommittedArtifactStore, createRenderAttempt, + createRendererNonceRegistry, createSlotOperation, type CommittedRenderArtifact, type RenderAttempt, @@ -23,6 +24,14 @@ import { 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, @@ -167,6 +176,615 @@ function attempt( return result.value; } +function rendererPort() { + return Object.freeze({ close: 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('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(); + }); +}); + function claimed( render: RenderAttempt, scope: TestOwner, From 759235f5aaadb7033d3596d29ba071342a3bbd6d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:13:18 -0700 Subject: [PATCH 044/194] Add owned browser message channels --- .../lib/src/adapters/messaging.ts | 436 +++++++++--- .../lib/test/adapters/messaging.test.ts | 658 +++++++++++++++++- 2 files changed, 1011 insertions(+), 83 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/messaging.ts b/crates/trusted-server-js/lib/src/adapters/messaging.ts index 4f3627db6..492e411af 100644 --- a/crates/trusted-server-js/lib/src/adapters/messaging.ts +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -1,10 +1,31 @@ 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, @@ -169,11 +190,12 @@ export type CaptureMessageListener = (event: MessageEvent) => void; 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[]): void; + post(message: unknown, transferred: readonly unknown[]): boolean; listen( messageListener: (event: unknown) => void, messageErrorListener: (event: unknown) => void @@ -181,8 +203,21 @@ export interface MessagingPort { 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; parseProtocolMessage( kind: ProtocolMessageKind, @@ -712,37 +747,100 @@ function parseProtocolMessage( } } -interface RawPort { +interface CapturedPortClose { readonly binding: object; - readonly add: (...arguments_: unknown[]) => unknown; 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; } -function rawPort(candidate: unknown): RawPort | undefined { - if ((typeof candidate !== 'object' || candidate === null) && typeof candidate !== 'function') { - return undefined; +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(candidate, 'addEventListener'); - const closePort = Reflect.get(candidate, 'close'); - const postMessage = Reflect.get(candidate, 'postMessage'); - const remove = Reflect.get(candidate, 'removeEventListener'); - const start = Reflect.get(candidate, 'start'); + 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 closePort !== 'function' || typeof postMessage !== 'function' || typeof remove !== 'function' || (start !== undefined && typeof start !== 'function') ) { - return undefined; + return { close, raw: undefined }; } - return { binding: candidate, add, closePort, postMessage, remove, start }; + return { + close, + raw: { binding, add, closePort: callableClose, postMessage, remove, start }, + }; } catch { - return undefined; + return { close, raw: undefined }; } } @@ -758,25 +856,48 @@ function closeRawPort(candidate: unknown): void { } } -function wrapPort(raw: RawPort): MessagingPort { +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>(); - let closed = false; - return Object.freeze({ - post: (message: unknown, transferred: readonly unknown[]): void => { - if (closed) return; + 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, [...transferred]]); + Reflect.apply(raw.postMessage, raw.binding, [message, reservation.rawTransfers]); } catch { - // A failed post remains local to the channel boundary. + rollbackTransferReservation(reservation); + return false; } + commitTransferReservation(reservation); + return true; }, listen: ( messageListener: (event: unknown) => void, messageErrorListener: (event: unknown) => void ): (() => void) => { - if (closed) return () => undefined; + if (state.transferable || state.closed || state.transferred || state.transferring) { + return () => undefined; + } const wrappedMessage = (event: unknown): void => { - if (closed) return; + if (state.closed || state.transferred) return; try { messageListener(event); } catch { @@ -784,7 +905,7 @@ function wrapPort(raw: RawPort): MessagingPort { } }; const wrappedMessageError = (event: unknown): void => { - if (closed) return; + if (state.closed || state.transferred) return; try { messageErrorListener(event); } catch { @@ -823,7 +944,7 @@ function wrapPort(raw: RawPort): MessagingPort { } }; const stopClosedSetup = (): boolean => { - if (!closed && active) return false; + if (!state.closed && !state.transferred && !state.transferring && active) return false; setupInProgress = false; rollback(); return true; @@ -869,9 +990,21 @@ function wrapPort(raw: RawPort): MessagingPort { return dispose; }, close: (): void => { - if (closed) return; - closed = true; - for (const dispose of [...listeners]) dispose(); + 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 { @@ -879,26 +1012,8 @@ function wrapPort(raw: RawPort): MessagingPort { } }, }); -} - -function closeUniquePorts(candidates: readonly unknown[]): void { - const closed: object[] = []; - for (let index = 0; index < candidates.length; index += 1) { - const candidate = candidates[index]; - if ((typeof candidate !== 'object' || candidate === null) && typeof candidate !== 'function') { - continue; - } - let duplicate = false; - for (let closedIndex = 0; closedIndex < closed.length; closedIndex += 1) { - if (closed[closedIndex] === candidate) { - duplicate = true; - break; - } - } - if (duplicate) continue; - closed[closed.length] = candidate as object; - closeRawPort(candidate); - } + setWrappedPortState(port, state); + return port; } function snapshotPortArray( @@ -919,27 +1034,43 @@ function snapshotPortArray( return undefined; } const length = lengthDescriptor.value; - const descriptors = Object.getOwnPropertyDescriptors(candidate); const ownKeys = Reflect.ownKeys(candidate); const values: unknown[] = []; let valid = length <= 2 && ownKeys.length === length + 1; - const numericKeys = ownKeys - .filter( - (key): key is string => - typeof key === 'string' && - /^(?:0|[1-9]\d*)$/.test(key) && - Number(key) < length && - Number.isSafeInteger(Number(key)) - ) - .sort((left, right) => Number(left) - Number(right)); - if (numericKeys.length !== length) valid = false; - for (const key of numericKeys) { - const descriptor = descriptors[key]; - if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { - valid = false; - continue; + 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) 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')) { + valid = false; + continue; + } + values[values.length] = descriptor.value; + } + } else { + for (let keyIndex = 0; keyIndex < ownKeys.length; keyIndex += 1) { + const key = ownKeys[keyIndex]; + if (typeof key !== 'string' || key === 'length') continue; + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || index >= length || String(index) !== key) { + continue; + } + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (descriptor && Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + values[values.length] = descriptor.value; + } } - values.push(descriptor.value); } return { valid, values }; } catch { @@ -947,6 +1078,46 @@ function snapshotPortArray( } } +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 extractTransferredPorts( event: unknown, expectedCount: 0 | 1 | 2 @@ -960,26 +1131,129 @@ function extractTransferredPorts( } const snapshot = snapshotPortArray(candidates); if (!snapshot) return undefined; - if (!snapshot.valid || snapshot.values.length !== expectedCount) { - closeUniquePorts(snapshot.values); + const inspections: Array = []; + const claimed: boolean[] = []; + let accepted = snapshot.valid && snapshot.values.length === expectedCount; + 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; } - if (expectedCount === 2 && snapshot.values[0] === snapshot.values[1]) { - closeRawPort(snapshot.values[0]); + 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; } - const ports: RawPort[] = []; - for (let index = 0; index < snapshot.values.length; index += 1) { - const port = rawPort(snapshot.values[index]); - if (!port) { - closeUniquePorts(snapshot.values); +} + +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; } - ports.push(port); + return Object.freeze({ + retained: wrapPort(retainedRaw), + transferred: wrapPort(transferredRaw, true), + }); + } catch { + cleanup(); + return undefined; } - const wrapped: MessagingPort[] = []; - for (const port of ports) wrapped.push(wrapPort(port)); - return Object.freeze(wrapped); +} + +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; } /** @@ -993,6 +1267,8 @@ export function createBrowserMessagingAdapter( validation: MessagingValidationOptions = {} ): MessagingAdapter { return Object.freeze({ + createChannel: () => createChannel(target), + postWindow, installCaptureListener(listener: CaptureMessageListener): () => void { let add: unknown; let remove: unknown; @@ -1040,6 +1316,8 @@ export function createBrowserMessagingAdapter( /** 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, parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => parseProtocolMessage(kind, candidate, {}), diff --git a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts index 0bf117525..bc39cea2e 100644 --- a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts @@ -46,6 +46,636 @@ function createApsRenderer() { } describe('browser messaging adapter', () => { + it('creates one owned channel and transfers only its exact wrapped endpoint', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const channel = adapter.createChannel(); + if (!channel) throw new Error('Expected one channel'); + expect(Object.isFrozen(channel)).toBe(true); + expect(Object.isFrozen(channel.retained)).toBe(true); + expect(Object.isFrozen(channel.transferred)).toBe(true); + + const receiver = { postMessage: vi.fn() }; + const envelope = Object.freeze({ version: 1, nonce: 'n1_abcdefghijklmnopqrstuv' }); + expect(adapter.postWindow(receiver, envelope, '*', [channel.transferred])).toBe(true); + expect(receiver.postMessage).toHaveBeenCalledWith(envelope, '*', [transferredRaw]); + channel.transferred.close(); + expect(transferredRaw.close).not.toHaveBeenCalled(); + expect(adapter.postWindow(receiver, envelope, '*', [channel.transferred])).toBe(false); + channel.retained.close(); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + }); + + it('leaves an untransferred endpoint locally closeable when exact window posting fails', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const channel = adapter.createChannel(); + if (!channel) throw new Error('Expected one channel'); + const receiver = { + postMessage: vi.fn(() => { + throw new Error('window post failed'); + }), + }; + expect(adapter.postWindow(receiver, Object.freeze({}), '*', [channel.transferred])).toBe(false); + channel.transferred.close(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + channel.retained.close(); + }); + + it('reserves a transferred endpoint before a reentrant window post', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const channel = adapter.createChannel(); + if (!channel) throw new Error('Expected one channel'); + let nested: boolean | undefined; + const receiver = { + postMessage: vi.fn(() => { + nested = adapter.postWindow(receiver, Object.freeze({ nested: true }), '*', [ + channel.transferred, + ]); + }), + }; + expect( + adapter.postWindow(receiver, Object.freeze({ outer: true }), '*', [channel.transferred]) + ).toBe(true); + expect(nested).toBe(false); + expect(receiver.postMessage).toHaveBeenCalledOnce(); + channel.transferred.close(); + expect(transferredRaw.close).not.toHaveBeenCalled(); + }); + + it('closes invalid channel endpoints without returning a partial facade', () => { + const duplicate = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = duplicate; + readonly port2 = duplicate; + }, + }); + expect(adapter.createChannel()).toBeUndefined(); + expect(duplicate.close).toHaveBeenCalledOnce(); + expect(createBrowserMessagingAdapter(createTarget()).createChannel()).toBeUndefined(); + }); + + it('transfers through descriptor snapshots when Array prototype operations are poisoned', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const channel = adapter.createChannel(); + if (!channel) throw new Error('Expected one channel'); + let posts = 0; + let receivedTransfer: unknown; + const receiver = { + postMessage(...parameters: unknown[]): void { + posts += 1; + receivedTransfer = parameters[2]; + }, + }; + const originalFilter = Object.getOwnPropertyDescriptor(Array.prototype, 'filter'); + const originalSort = Object.getOwnPropertyDescriptor(Array.prototype, 'sort'); + const originalPush = Object.getOwnPropertyDescriptor(Array.prototype, 'push'); + const originalIterator = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const poisoned = (): never => { + throw new Error('poisoned Array prototype operation'); + }; + let result: boolean | undefined; + let thrown: unknown; + try { + Object.defineProperty(Array.prototype, 'filter', { value: poisoned, configurable: true }); + Object.defineProperty(Array.prototype, 'sort', { value: poisoned, configurable: true }); + Object.defineProperty(Array.prototype, 'push', { value: poisoned, configurable: true }); + Object.defineProperty(Array.prototype, Symbol.iterator, { + value: poisoned, + configurable: true, + }); + result = adapter.postWindow(receiver, Object.freeze({}), '*', [channel.transferred]); + } catch (error) { + thrown = error; + } finally { + if (originalFilter) Object.defineProperty(Array.prototype, 'filter', originalFilter); + if (originalSort) Object.defineProperty(Array.prototype, 'sort', originalSort); + if (originalPush) Object.defineProperty(Array.prototype, 'push', originalPush); + if (originalIterator) { + Object.defineProperty(Array.prototype, Symbol.iterator, originalIterator); + } + } + + expect(thrown).toBeUndefined(); + expect(result).toBe(true); + expect(posts).toBe(1); + expect(receivedTransfer).toEqual([transferredRaw]); + channel.retained.close(); + }); + + it('drains listeners and closes the raw port when collection iterators are poisoned', () => { + let messageListener: unknown; + let messageErrorListener: unknown; + let removals = 0; + let closes = 0; + const raw = { + addEventListener(type: string, listener: unknown): void { + if (type === 'message') messageListener = listener; + else messageErrorListener = listener; + }, + close(): void { + closes += 1; + }, + postMessage(): void {}, + removeEventListener(type: string, listener: unknown): void { + if (type === 'message' && listener === messageListener) removals += 1; + if (type === 'messageerror' && listener === messageErrorListener) removals += 1; + }, + start(): void {}, + }; + const adapter = createBrowserMessagingAdapter(createTarget()); + const [port] = adapter.extractTransferredPorts({ ports: [raw] }, 1) ?? []; + if (!port) throw new Error('Expected one port'); + port.listen( + () => undefined, + () => undefined + ); + + const originalSetIterator = Object.getOwnPropertyDescriptor(Set.prototype, Symbol.iterator); + const originalSetValues = Object.getOwnPropertyDescriptor(Set.prototype, 'values'); + const originalArrayIterator = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const iteratorPrototype = Object.getPrototypeOf(new Set().values()) as object; + const originalNext = Object.getOwnPropertyDescriptor(iteratorPrototype, 'next'); + const poisoned = (): never => { + throw new Error('poisoned collection iterator'); + }; + let thrown: unknown; + try { + Object.defineProperty(Set.prototype, Symbol.iterator, { + value: poisoned, + configurable: true, + }); + Object.defineProperty(Set.prototype, 'values', { value: poisoned, configurable: true }); + Object.defineProperty(Array.prototype, Symbol.iterator, { + value: poisoned, + configurable: true, + }); + Object.defineProperty(iteratorPrototype, 'next', { value: poisoned, configurable: true }); + port.close(); + } catch (error) { + thrown = error; + } finally { + if (originalSetIterator) { + Object.defineProperty(Set.prototype, Symbol.iterator, originalSetIterator); + } + if (originalSetValues) Object.defineProperty(Set.prototype, 'values', originalSetValues); + if (originalArrayIterator) { + Object.defineProperty(Array.prototype, Symbol.iterator, originalArrayIterator); + } + if (originalNext) Object.defineProperty(iteratorPrototype, 'next', originalNext); + } + + expect(thrown).toBeUndefined(); + expect(removals).toBe(2); + expect(closes).toBe(1); + port.close(); + expect(closes).toBe(1); + }); + + it('posts through a wrapped port without dynamic transfer-array iteration', () => { + const raw = createPort(); + const adapter = createBrowserMessagingAdapter(createTarget()); + const [port] = adapter.extractTransferredPorts({ ports: [raw] }, 1) ?? []; + if (!port) throw new Error('Expected one port'); + const transferred: unknown[] = []; + const originalIterator = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const poisoned = (): never => { + throw new Error('poisoned Array iterator'); + }; + try { + Object.defineProperty(Array.prototype, Symbol.iterator, { + value: poisoned, + configurable: true, + }); + port.post(Object.freeze({ message: true }), transferred); + } finally { + if (originalIterator) { + Object.defineProperty(Array.prototype, Symbol.iterator, originalIterator); + } + } + + expect(raw.postMessage).toHaveBeenCalledWith({ message: true }, []); + port.close(); + }); + + it('unwraps and commits exact channel endpoints transferred through a retained port', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const controlRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const channel = adapter.createChannel(); + const [control] = adapter.extractTransferredPorts({ ports: [controlRaw] }, 1) ?? []; + if (!channel || !control) throw new Error('Expected channel and control port'); + const message = Object.freeze({ message: 'transfer' }); + + expect(control.post(message, [channel.transferred])).toBe(true); + expect(controlRaw.postMessage).toHaveBeenCalledWith(message, [transferredRaw]); + expect(control.post(message, [channel.transferred])).toBe(false); + channel.transferred.close(); + expect(transferredRaw.close).not.toHaveBeenCalled(); + channel.retained.close(); + control.close(); + }); + + it('rolls back exact channel transfer ownership when retained-port posting throws', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const controlRaw = createPort(); + controlRaw.postMessage.mockImplementation(() => { + throw new Error('port post failed'); + }); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const channel = adapter.createChannel(); + const [control] = adapter.extractTransferredPorts({ ports: [controlRaw] }, 1) ?? []; + if (!channel || !control) throw new Error('Expected channel and control port'); + + expect(control.post(Object.freeze({}), [channel.transferred])).toBe(false); + channel.transferred.close(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + channel.retained.close(); + control.close(); + }); + + it('uses captured close authority when later channel validation fails', () => { + let closeReads = 0; + let closes = 0; + const first = { + addEventListener(): void {}, + get close(): () => void { + closeReads += 1; + if (closeReads > 1) throw new Error('close authority re-read'); + return () => { + closes += 1; + }; + }, + postMessage(): void {}, + removeEventListener(): void {}, + start(): void {}, + }; + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = first; + readonly port2 = Object.freeze({ invalid: true }); + }, + }); + + expect(adapter.createChannel()).toBeUndefined(); + expect(closeReads).toBe(1); + expect(closes).toBe(1); + }); + + it('preserves captured close authority when later raw-port method inspection throws', () => { + const first = createPort(); + let closeReads = 0; + let closes = 0; + const partial = { + addEventListener(): void {}, + get close(): () => void { + closeReads += 1; + if (closeReads > 1) throw new Error('close authority re-read'); + return () => { + closes += 1; + }; + }, + get postMessage(): never { + throw new Error('later port inspection failed'); + }, + removeEventListener(): void {}, + start(): void {}, + }; + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = first; + readonly port2 = partial; + }, + }); + + expect(adapter.createChannel()).toBeUndefined(); + expect(closeReads).toBe(1); + expect(closes).toBe(1); + expect(first.close).toHaveBeenCalledOnce(); + }); + + it('captures the first endpoint close before a hostile second-endpoint getter runs', () => { + let closeReads = 0; + let poisonedCloseReads = 0; + let closes = 0; + const first = { + addEventListener(): void {}, + get close(): () => void { + closeReads += 1; + if (closeReads > 1) throw new Error('first close authority re-read'); + return () => { + closes += 1; + }; + }, + postMessage(): void {}, + removeEventListener(): void {}, + start(): void {}, + }; + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = first; + + get port2(): never { + Object.defineProperty(first, 'close', { + configurable: true, + get: () => { + poisonedCloseReads += 1; + throw new Error('first close authority poisoned'); + }, + }); + throw new Error('second endpoint unavailable'); + } + }, + }); + + expect(adapter.createChannel()).toBeUndefined(); + expect(closeReads).toBe(1); + expect(poisonedCloseReads).toBe(0); + expect(closes).toBe(1); + }); + + it('uses captured WeakMap authority for channel registration and facade lookup', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const originalGet = WeakMap.prototype.get; + const originalSet = WeakMap.prototype.set; + let dynamicGets = 0; + let dynamicSets = 0; + WeakMap.prototype.set = function ( + this: WeakMap, + key: K, + value: V + ): WeakMap { + dynamicSets += 1; + Reflect.apply(originalSet, this, [key, value]); + throw new Error('registration intercepted'); + }; + WeakMap.prototype.get = function (this: WeakMap, _key: K): V { + dynamicGets += 1; + return { + raw: { binding: transferredRaw }, + transferable: true, + closed: false, + transferred: false, + transferring: false, + } as V; + }; + + let channel: ReturnType; + let forgedResult: boolean | undefined; + let thrown: unknown; + let posts = 0; + try { + channel = adapter.createChannel(); + forgedResult = adapter.postWindow( + { postMessage: () => (posts += 1) }, + Object.freeze({}), + '*', + [Object.freeze({}) as never] + ); + } catch (error) { + thrown = error; + } finally { + WeakMap.prototype.get = originalGet; + WeakMap.prototype.set = originalSet; + } + + expect(thrown).toBeUndefined(); + expect(channel).toBeDefined(); + expect(forgedResult).toBe(false); + expect(posts).toBe(0); + expect(dynamicGets).toBe(0); + expect(dynamicSets).toBe(0); + channel?.retained.close(); + channel?.transferred.close(); + }); + + it('rejects MessageChannel constructors that reuse an already-owned raw endpoint', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const first = adapter.createChannel(); + if (!first) throw new Error('Expected one channel'); + + expect(adapter.createChannel()).toBeUndefined(); + expect(retainedRaw.close).not.toHaveBeenCalled(); + expect(transferredRaw.close).not.toHaveBeenCalled(); + first.retained.close(); + first.transferred.close(); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + }); + + it('does not close a live owned endpoint when a later constructor returns it twice', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + let constructions = 0; + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1: unknown; + readonly port2: unknown; + + constructor() { + constructions += 1; + this.port1 = retainedRaw; + this.port2 = constructions === 1 ? transferredRaw : retainedRaw; + } + }, + }); + const first = adapter.createChannel(); + if (!first) throw new Error('Expected one channel'); + + expect(adapter.createChannel()).toBeUndefined(); + expect(retainedRaw.close).not.toHaveBeenCalled(); + expect(transferredRaw.close).not.toHaveBeenCalled(); + first.retained.close(); + first.transferred.close(); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + }); + + it('keeps failed channel bindings retired throughout reentrant close cleanup', () => { + let constructions = 0; + let closes = 0; + let nestedCloses = 0; + let nestedResult: ReturnType['createChannel']>; + const retainedRaw = { + addEventListener(): void {}, + close(): void { + closes += 1; + nestedResult = adapter.createChannel(); + }, + postMessage(): void {}, + removeEventListener(): void {}, + start(): void {}, + }; + const nestedRaw = { + addEventListener(): void {}, + close(): void { + nestedCloses += 1; + }, + postMessage(): void {}, + removeEventListener(): void {}, + start(): void {}, + }; + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1: unknown; + readonly port2: unknown; + + constructor() { + constructions += 1; + this.port1 = retainedRaw; + this.port2 = constructions === 1 ? Object.freeze({ invalid: true }) : nestedRaw; + } + }, + }); + + expect(adapter.createChannel()).toBeUndefined(); + expect(nestedResult).toBeUndefined(); + expect(closes).toBe(1); + expect(nestedCloses).toBe(1); + }); + + it('rejects channel-owned raw endpoints at transferred-port extraction without closing them', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const channel = adapter.createChannel(); + if (!channel) throw new Error('Expected one channel'); + + expect(adapter.extractTransferredPorts({ ports: [retainedRaw] }, 1)).toBeUndefined(); + expect(adapter.extractTransferredPorts({ ports: [retainedRaw] }, 0)).toBeUndefined(); + expect(retainedRaw.close).not.toHaveBeenCalled(); + channel.retained.close(); + channel.transferred.close(); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + }); + + it('rejects MessageChannel endpoints already owned by transferred-port extraction', () => { + const extractedRaw = createPort(); + const newRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = extractedRaw; + readonly port2 = newRaw; + }, + }); + const [extracted] = adapter.extractTransferredPorts({ ports: [extractedRaw] }, 1) ?? []; + if (!extracted) throw new Error('Expected one extracted port'); + + expect(adapter.createChannel()).toBeUndefined(); + expect(extractedRaw.close).not.toHaveBeenCalled(); + expect(newRaw.close).toHaveBeenCalledOnce(); + extracted.close(); + expect(extractedRaw.close).toHaveBeenCalledOnce(); + }); + + it('extracts and wraps transferred ports without dynamic Array operations or iteration', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const raw = createPort(); + const originalPush = Object.getOwnPropertyDescriptor(Array.prototype, 'push'); + const originalIterator = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const poisoned = (): never => { + throw new Error('poisoned Array operation'); + }; + let extracted: readonly unknown[] | undefined; + let thrown: unknown; + try { + Object.defineProperty(Array.prototype, 'push', { value: poisoned, configurable: true }); + Object.defineProperty(Array.prototype, Symbol.iterator, { + value: poisoned, + configurable: true, + }); + extracted = adapter.extractTransferredPorts({ ports: [raw] }, 1); + } catch (error) { + thrown = error; + } finally { + if (originalPush) Object.defineProperty(Array.prototype, 'push', originalPush); + if (originalIterator) { + Object.defineProperty(Array.prototype, Symbol.iterator, originalIterator); + } + } + + expect(thrown).toBeUndefined(); + expect(extracted).toHaveLength(1); + const port = extracted?.[0] as { close?: () => void } | undefined; + port?.close?.(); + expect(raw.close).toHaveBeenCalledOnce(); + }); + it('centralizes every protocol literal and exact message shape as frozen data', () => { expect(TSJS_MESSAGE_PROTOCOL_V1).toEqual({ version: 1, @@ -495,12 +1125,13 @@ describe('browser messaging adapter', () => { it('extracts exactly zero, one, or two transferred ports into frozen narrow facades', () => { const adapter = createBrowserMessagingAdapter(createTarget()); - const first = createPort(); - const second = createPort(); + const single = createPort(); + const pairFirst = createPort(); + const pairSecond = createPort(); const zero = adapter.extractTransferredPorts({ ports: [] }, 0); - const one = adapter.extractTransferredPorts({ ports: [first] }, 1); - const two = adapter.extractTransferredPorts({ ports: [first, second] }, 2); + const one = adapter.extractTransferredPorts({ ports: [single] }, 1); + const two = adapter.extractTransferredPorts({ ports: [pairFirst, pairSecond] }, 2); expect(zero).toEqual([]); expect(one).toHaveLength(1); @@ -572,6 +1203,25 @@ describe('browser messaging adapter', () => { 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(); From a4810c154f2a1acca55cb8ef888b24aee573d0e3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:36:15 -0700 Subject: [PATCH 045/194] Implement direct APS renderer flow --- .../lib/src/composition/browser.ts | 29 + .../lib/src/integrations/aps/render.ts | 672 +++++++++- .../lib/src/services/render.ts | 50 +- .../composition/browser-node-import.test.ts | 16 + .../lib/test/composition/browser.test.ts | 6 + .../lib/test/services/render.test.ts | 1092 +++++++++++++++++ ...8-04-aps-tsjs-resilience-implementation.md | 10 +- ...s-render-fix-and-tsjs-resilience-design.md | 34 +- 8 files changed, 1894 insertions(+), 15 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/composition/browser-node-import.test.ts diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 7b4122615..555195540 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -23,6 +23,7 @@ import { parseBrowserAuctionProjectionV1, } from '../core/contracts/auction_projection'; import { validateApsRenderer } from '../core/contracts/aps_renderer'; +import { renderDirectApsAttempt } from '../integrations/aps/render'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; import { createRuntimeSession } from '../kernel/sessions'; @@ -35,6 +36,11 @@ import { prepareInitialAuctionProjection, } from '../services/projections'; import { createReservationService, type ReservationService } from '../services/reservations'; +import { + createRendererNonceRegistry, + type RenderAttempt, + type RendererNonceRegistry, +} from '../services/render'; import { createSlotService, type SlotService } from '../services/slots'; import { createTargetingService, type TargetingService } from '../services/targeting'; @@ -50,6 +56,8 @@ export interface BrowserComposition { export interface BrowserServices { readonly reservations: ReservationService; + readonly rendererNonces: RendererNonceRegistry; + readonly renderDirectAps: (attempt: RenderAttempt, container: HTMLElement) => boolean; readonly slots: SlotService; readonly targeting: TargetingService; } @@ -78,6 +86,8 @@ export interface BrowserRuntimeComposition extends BrowserComposition { 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; } export interface BrowserCoreActivations { @@ -203,8 +213,25 @@ export function createTestBrowserRuntimeComposition( const reservationService = createReservationService({ prepareRenderSource: (candidate) => parseBidRenderSourceV1(candidate, cachePolicy), }); + const rendererNonces = createRendererNonceRegistry(); + const publisherOrigin = window.location.origin; + const renderDirectAps = (attempt: RenderAttempt, container: HTMLElement): boolean => { + try { + return renderDirectApsAttempt({ + attempt, + container, + messaging: composition.adapters.messaging, + nonces: rendererNonces, + publisherOrigin, + }); + } catch { + return false; + } + }; const services = Object.freeze({ reservations: reservationService, + rendererNonces, + renderDirectAps, slots: slotService, targeting: targetingService, }); @@ -216,6 +243,7 @@ export function createTestBrowserRuntimeComposition( context.onDispose(() => { session.dispose(); reservationService.dispose(); + rendererNonces.dispose(); slotService.dispose(); targetingService.dispose(); composition.adapters.googletag.dispose(); @@ -283,5 +311,6 @@ export function createTestBrowserRuntimeComposition( slotServiceForTest: () => browserServices?.slots, targetingServiceForTest: () => browserServices?.targeting, reservationServiceForTest: () => browserServices?.reservations, + rendererNonceRegistryForTest: () => browserServices?.rendererNonces, }); } 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 e01f4781d..3421ab579 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -1,12 +1,81 @@ import { log } from '../../core/log'; import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } 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_PATH = '/integrations/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; @@ -22,9 +91,12 @@ const MAX_PREBID_RENDERER_TTL_SECONDS = 3600; const MAX_PREBID_ID_BYTES = 1024; /** Validate, copy, and freeze one APS tagged render source. */ -export function prepareApsRenderSource(input: unknown): Readonly | undefined { +export function prepareApsRenderSource( + input: unknown, + publisherOrigin?: string +): Readonly | undefined { try { - const renderer = validateApsRenderer(input); + const renderer = validateApsRenderer(input, publisherOrigin); return renderer ? (Reflect.apply(objectFreezeIntrinsic, Object, [renderer]) as Readonly) : undefined; @@ -177,6 +249,602 @@ export interface RenderApsCreativeOptions { renderer: unknown; } +export interface DirectApsAttemptOptions { + readonly attempt: RenderAttempt; + readonly container: HTMLElement; + readonly messaging: MessagingAdapter; + readonly nonces: RendererNonceRegistry; + readonly publisherOrigin: string; +} + +function freeze(value: Value): Readonly { + return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; +} + +export function resolveApsRendererV1Url(publisherOrigin: string): string | undefined { + try { + 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 closeChannel(channel: MessagingChannel | undefined): void { + try { + channel?.transferred.close(); + } catch { + // The second endpoint must still be attempted when the first close is hostile. + } + try { + channel?.retained.close(); + } catch { + // Failed construction cleanup remains best-effort. + } +} + +function mapNonceIssueFailure( + reason: 'capability_registry_full' | 'identity_generation_failed' | 'invalid_attempt' +): RenderFailureReason { + return reason === 'invalid_attempt' ? 'internal_error' : reason; +} + +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 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; + } +} + +/** Drive one direct APS attempt through the versioned static renderer document. */ +export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolean { + if ( + !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; + } + 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; + } + 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; + } + + const fail = (reason: RenderFailureReason): false => { + try { + Reflect.apply(failMethod, attempt, [reason]); + } catch { + // The attempt's terminal latch owns failure authority. + } + return false; + }; + + const attemptState = (): ReturnType['state'] | undefined => { + try { + return Reflect.apply(snapshotMethod, attempt, []).state; + } catch { + return undefined; + } + }; + + let iframe: HTMLIFrameElement; + try { + if ( + 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 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]]); + } + } + } catch { + return fail('renderer_document_no_load'); + } + + let channel: MessagingChannel | undefined; + try { + channel = Reflect.apply(createChannelMethod, messaging, []); + } catch { + channel = undefined; + } + 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 startupFailure = (reason: RenderFailureReason): false => { + if (!artifactOwnedByAttempt) artifact.dispose(); + return fail(reason); + }; + + const nonceExpectation = (source: object) => + freeze({ nonce, attempt, generation: attemptGeneration, source, port: channel!.retained }); + + const messageData = (event: unknown): unknown => { + try { + return typeof event === 'object' && event !== null ? Reflect.get(event, 'data') : undefined; + } catch { + return undefined; + } + }; + + 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 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 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; + } + }; + + const receive = (event: unknown): void => { + if (disposed || !boundSource || !envelopeTransferred) return; + if (!exactFrameBinding()) { + fail(documentAccepted ? 'runner_failed' : 'renderer_document_no_load'); + return; + } + 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(); + } + + function onError(): void { + if ( + disposed || + envelopeTransferred || + !sourceAssigned || + (!insertionCommitted && + !(appendInProgress && Reflect.apply(nodeParentNodeGetter!, iframe, []) === container)) + ) { + return; + } + errorObserved = true; + if (artifactOwnedByAttempt) fail('renderer_document_no_load'); + } + + 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'); + } +} + /** Render APS through the static endpoint under an outer opaque-origin sandbox. */ export function renderApsCreative({ slotId, renderer: input }: RenderApsCreativeOptions): boolean { const renderer = prepareApsRenderSource(input); diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index 9d848bc2f..da2a2ded6 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -369,13 +369,14 @@ export interface RendererNoncePort { export interface RendererNonceIssueInput { readonly attempt: RenderAttempt; - readonly source: object; + readonly source?: object; readonly port: RendererNoncePort; } export interface RendererNonceExpectation extends RendererNonceIssueInput { readonly nonce: string; readonly generation: object; + readonly source: object; } export type RendererNonceIssueResult = @@ -394,6 +395,8 @@ export interface RendererNonceRegistrySnapshot { export interface RendererNonceRegistry { /** On failure the caller retains port ownership; success transfers it to this registry. */ readonly issue: (input: RendererNonceIssueInput) => RendererNonceIssueResult; + /** Bind a pre-insertion nonce exactly once to the inserted renderer's WindowProxy. */ + readonly bindSource: (expectation: RendererNonceExpectation) => boolean; readonly consume: (expectation: RendererNonceExpectation) => boolean; readonly dispose: () => void; readonly snapshotForTest: () => RendererNonceRegistrySnapshot; @@ -1398,7 +1401,7 @@ interface RendererNonceBinding { readonly attempt: RenderAttempt; readonly attemptId: string; readonly generation: object; - readonly source: object; + source: object | undefined; readonly port: RendererNoncePort; readonly closeMethod: RendererNoncePort['close']; consumed: boolean; @@ -1541,7 +1544,7 @@ export function createRendererNonceRegistry( const registry: RendererNonceRegistry = { issue(input): RendererNonceIssueResult { let attempt: RenderAttempt; - let source: object; + let source: object | undefined; let port: RendererNoncePort; let closeMethod: RendererNoncePort['close']; let attemptId: string; @@ -1560,8 +1563,8 @@ export function createRendererNonceRegistry( if ( disposed || !weakSetHas(renderAttempts, attempt) || - (typeof source !== 'object' && typeof source !== 'function') || - source === null || + (source !== undefined && + ((typeof source !== 'object' && typeof source !== 'function') || source === null)) || (typeof port !== 'object' && typeof port !== 'function') || port === null || typeof closeMethod !== 'function' || @@ -1696,6 +1699,42 @@ export function createRendererNonceRegistry( pendingCount -= 1; } }, + bindSource(expectation): boolean { + try { + const fields = readRendererNonceExpectation(expectation); + if ( + disposed || + !fields || + !validRendererNonce(fields.nonce) || + (typeof fields.source !== 'object' && typeof fields.source !== 'function') || + fields.source === null + ) { + return false; + } + const binding = mapGet(liveByNonce, fields.nonce); + if ( + !binding || + binding.closed || + binding.consumed || + binding.source !== undefined || + !setHas(bindings, binding) || + fields.attempt !== binding.attempt || + fields.generation !== binding.generation || + fields.port !== binding.port || + weakMapGet(bindingByPort, binding.port) !== binding || + binding.attempt.id !== binding.attemptId || + binding.attempt.generation !== binding.generation || + Reflect.apply(binding.attempt.snapshot, binding.attempt, []).outcome !== undefined || + mapGet(liveByNonce, binding.nonce) !== binding + ) { + return false; + } + binding.source = fields.source; + return true; + } catch { + return false; + } + }, consume(expectation): boolean { try { const fields = readRendererNonceExpectation(expectation); @@ -1705,6 +1744,7 @@ export function createRendererNonceRegistry( !binding || binding.closed || binding.consumed || + binding.source === undefined || !setHas(bindings, binding) || fields.attempt !== binding.attempt || fields.generation !== binding.generation || 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..cbbf98a42 --- /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'); + 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 index 788b089c8..9190244a3 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -362,12 +362,16 @@ describe('browser composition', () => { 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?.currentNavigation?.interfaces).toBe(session?.interfaces); expect(session?.currentNavigation?.currentAuctionProjection).toEqual(projection); expect(Object.isFrozen(session?.currentNavigation?.currentAuctionProjection)).toBe(true); @@ -414,9 +418,11 @@ describe('browser composition', () => { 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('unwinds a lazily-created session when navigation identity generation fails', async () => { diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index f60c00a2a..bd6900eb7 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it, vi } from 'vitest'; +import apsEnvelope from '../fixtures/aps-renderer-v1.json'; +import { createBrowserMessagingAdapter, type MessagingAdapter } from '../../src/adapters/messaging'; 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, @@ -52,11 +60,26 @@ const APS_SOURCE = Object.freeze({ 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 }); function prepareRenderSource(candidate: unknown) { if (candidate === ADM_SOURCE) return ADM_SOURCE; if (candidate === APS_SOURCE) return APS_SOURCE; + if (candidate === DIRECT_APS_SOURCE) return DIRECT_APS_SOURCE; return undefined; } @@ -180,6 +203,28 @@ 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; @@ -350,6 +395,124 @@ describe('renderer nonce registry', () => { 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({ @@ -785,6 +948,935 @@ describe('renderer nonce registry', () => { }); }); +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, diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index 35e658f1b..6865982d9 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -1414,7 +1414,15 @@ Every task's regression suite therefore remains green in task order. generation, renderer `contentWindow`, and retained port; - put the nonce in the fragment and transfer an envelope containing the kernel-captured publisher origin; - - bind to the exact iframe `contentWindow` and transferred port; + - use captured native document, tree, event, attribute, source, and removal + authorities to create one fresh iframe in the exact publisher document; never + accept a publisher-supplied connected or detached frame, forged `contentWindow`, + lying `src`, or hostile cleanup method; + - bind to the exact iframe browsing-context `contentWindow` and transferred port, + fail detectable node removal/replacement or `src` mutation, and preserve the + explicit §4.4 ancestor-navigation trust boundary: an opaque active `Document` + cannot be attested after undetectable `contentWindow.location` navigation, so no + test or release evidence may claim otherwise; - atomically consume the nonce on the first valid document acceptance and invalidate it on failure, supersession, navigation, or disposal; duplicate, wrong-source, stale, or late use is inert and nonce values are never logged; diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index beb692f7d..ec4767208 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1578,6 +1578,28 @@ on iframe load transfers the envelope and document port once to that exact `contentWindow`. Direct APS uses the same document channel and envelope but has no PUC owner-control channel. The nonce is 128-bit CSPRNG, attempt-bound, and one-use. +The enforceable direct-path binding is to one TS-created native iframe element, its +unchanged `src` attribute, its browsing-context `WindowProxy`, and the one-use port; +it is not browser attestation of the active opaque `Document`. Code executing in an +embedding ancestor realm with DOM/navigation authority is trusted for this one +navigation-integrity property. Such code can assign +`iframe.contentWindow.location` without changing the iframe `src`, while the same +`WindowProxy` survives and the opaque active document's URL and origin remain +unreadable to the kernel. If it does so before handoff, that replacement document +can receive the descriptor, nonce, and port and can forge the page-local document +and completion messages. Native element creation plus exact parent/source/`src` +checks still reject publisher-supplied frames, node replacement, removal, detectable +`src` mutation, unrelated contexts, and stale ports; they make no claim about the +undetectable ancestor-navigation case. APS has no synthetic notification or other +trusted remote side effect derived from page-local completion. + +Removing that trust boundary requires a separately operated renderer origin, adding +`allow-same-origin` only for that cross-origin document, and using exact +`targetOrigin`/`event.origin` checks. Adding `allow-same-origin` to the current +publisher-origin renderer would defeat containment when combined with scripts, so +that is not an acceptable implementation of this design and a dedicated-origin +variant requires a separate architecture decision. + The static document sends only these exact document-port messages: - `{message:"TS APS Document Accepted",version:1,nonce}` after nonce and descriptor @@ -2481,11 +2503,7 @@ subscription methods. The final schema is: ```ts type RenderTracePathV1 = 'auction' | 'ssat' | 'gam-refresh' type RenderTraceServedFromV1 = - | 'inline' - | 'gam' - | 'debug-adm' - | 'pbs-cache' - | 'prebid' + 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid' interface RenderTraceRecord { readonly slotId: string @@ -2772,8 +2790,10 @@ waived by a performance pass. ## 6. Security and privacy 1. Renderer iframes omit `allow-same-origin`; cross-origin target `"*"` is permitted - only when transferring a one-use port to an exact, already-checked - `contentWindow`. + only when transferring a one-use port to the exact native iframe's already-checked + browsing-context `WindowProxy`. As §4.4 states, this binds the context, not an + opaque active `Document`; embedding-ancestor code with navigation authority is + trusted for that navigation-integrity property. 2. The initial global PUC request contains the opaque renderer reservation capability but no descriptor, ADM, lifecycle ticket, or nonce, and it establishes no success. The first compatible claim acquires the PUC source; render authority From a515040882ee034789728ff01856ad9c8014ad3c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:05:14 -0700 Subject: [PATCH 046/194] feat(tsjs): implement direct ADM rendering --- .../lib/src/composition/browser.ts | 16 + .../trusted-server-js/lib/src/core/render.ts | 477 +++++++++++++++++- .../lib/src/services/render.ts | 266 +++++++++- .../lib/test/composition/browser.test.ts | 1 + .../lib/test/core/render.test.ts | 125 +++++ .../lib/test/services/render.test.ts | 365 ++++++++++++++ 6 files changed, 1243 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 555195540..3dff7037a 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -23,6 +23,7 @@ import { parseBrowserAuctionProjectionV1, } from '../core/contracts/auction_projection'; import { validateApsRenderer } from '../core/contracts/aps_renderer'; +import { prepareAdmIframe } from '../core/render'; import { renderDirectApsAttempt } from '../integrations/aps/render'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; @@ -38,6 +39,7 @@ import { import { createReservationService, type ReservationService } from '../services/reservations'; import { createRendererNonceRegistry, + renderDirectAdmAttempt, type RenderAttempt, type RendererNonceRegistry, } from '../services/render'; @@ -57,6 +59,7 @@ export interface BrowserComposition { export interface BrowserServices { readonly reservations: ReservationService; readonly rendererNonces: RendererNonceRegistry; + readonly renderDirectAdm: (attempt: RenderAttempt, container: HTMLElement) => boolean; readonly renderDirectAps: (attempt: RenderAttempt, container: HTMLElement) => boolean; readonly slots: SlotService; readonly targeting: TargetingService; @@ -215,6 +218,18 @@ export function createTestBrowserRuntimeComposition( }); const rendererNonces = createRendererNonceRegistry(); const publisherOrigin = window.location.origin; + const renderDirectAdm = (attempt: RenderAttempt, container: HTMLElement): boolean => { + try { + return renderDirectAdmAttempt({ + attempt, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin, + }); + } catch { + return false; + } + }; const renderDirectAps = (attempt: RenderAttempt, container: HTMLElement): boolean => { try { return renderDirectApsAttempt({ @@ -231,6 +246,7 @@ export function createTestBrowserRuntimeComposition( const services = Object.freeze({ reservations: reservationService, rendererNonces, + renderDirectAdm, renderDirectAps, slots: slotService, targeting: targetingService, diff --git a/crates/trusted-server-js/lib/src/core/render.ts b/crates/trusted-server-js/lib/src/core/render.ts index f41f29960..4b6e189ac 100644 --- a/crates/trusted-server-js/lib/src/core/render.ts +++ b/crates/trusted-server-js/lib/src/core/render.ts @@ -26,6 +26,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 = { @@ -228,10 +319,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 +342,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/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index da2a2ded6..e1ad3046d 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -14,6 +14,12 @@ const ATTEMPT_ID = /^a1_[A-Za-z0-9_-]{22}$/; const RENDERER_NONCE = /^n1_[A-Za-z0-9_-]{22}$/; const MAX_RENDERER_NONCES = 256; const MAX_RENDERER_NONCE_DRAWS = 8; +const reflectApplyIntrinsic = Reflect.apply; +const directAdmDocument = typeof document === 'undefined' ? undefined : document; +const directAdmOwnerDocumentGetter = + typeof Node === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Node.prototype, 'ownerDocument')?.get; const objectFreezeIntrinsic = Object.freeze; const arrayIncludesIntrinsic = Array.prototype.includes; const arrayPushIntrinsic = Array.prototype.push; @@ -59,7 +65,7 @@ const renderAttempts = new WeakSet(); const ignoreAsyncDisposal = (): void => undefined; function frozen(value: Value): Readonly { - return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; + return reflectApplyIntrinsic(objectFreezeIntrinsic, Object, [value]) as Readonly; } function arrayPush(array: Value[], value: Value): number { @@ -362,6 +368,31 @@ export interface RenderAttempt { readonly snapshot: () => RenderAttemptSnapshot; } +export interface DirectAdmAttemptOptions { + readonly attempt: RenderAttempt; + readonly container: HTMLElement; + readonly prepareIframe: DirectAdmIframeConstructor; + readonly publisherOrigin: string; +} + +export interface DirectAdmIframeHandle { + readonly frame: HTMLIFrameElement; + append(): boolean; + activate(): boolean; + commit(): boolean; + current(): boolean; + dispose(): void; +} + +export type DirectAdmIframeConstructor = (options: { + readonly adm: string; + readonly container: HTMLElement; + readonly height: number; + readonly onError: () => void; + readonly onLoad: () => void; + readonly width: number; +}) => DirectAdmIframeHandle | undefined; + /** Retained endpoint whose lifetime is owned by one renderer nonce binding. */ export interface RendererNoncePort { readonly close: () => void; @@ -1396,6 +1427,239 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp return frozen({ ok: true, value: frozen(lifecycle) }); } +type DirectAdmSource = Readonly<{ + adm: string; + height: number; + type: 'adm'; + version: 1; + width: number; +}>; + +function readDirectAdmSource(value: unknown): DirectAdmSource | 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).sort(); + const expected = ['adm', 'height', 'type', 'version', 'width']; + if (names.length !== expected.length) return undefined; + for (let index = 0; index < expected.length; index += 1) { + if (names[index] !== expected[index]) return undefined; + } + const fields = Object.create(null) as Record; + for (const name of expected) { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if ( + !descriptor || + !('value' in descriptor) || + descriptor.enumerable !== true || + descriptor.configurable !== false || + descriptor.writable !== false + ) { + return undefined; + } + fields[name] = descriptor.value; + } + if ( + fields['type'] !== 'adm' || + fields['version'] !== 1 || + typeof fields['adm'] !== 'string' || + fields['adm'].trim().length === 0 || + new TextEncoder().encode(fields['adm']).byteLength > 512 * 1024 || + typeof fields['width'] !== 'number' || + !Number.isInteger(fields['width']) || + fields['width'] < 1 || + fields['width'] > 4096 || + typeof fields['height'] !== 'number' || + !Number.isInteger(fields['height']) || + fields['height'] < 1 || + fields['height'] > 4096 + ) { + return undefined; + } + return value as DirectAdmSource; + } catch { + return undefined; + } +} + +/** Drive one admitted direct ADM attempt through the shared iframe constructor. */ +export function renderDirectAdmAttempt(options: DirectAdmAttemptOptions): boolean { + let attempt: RenderAttempt; + let container: HTMLElement; + let prepareIframe: DirectAdmIframeConstructor; + let publisherOrigin: string; + try { + attempt = options.attempt; + container = options.container; + prepareIframe = options.prepareIframe; + publisherOrigin = options.publisherOrigin; + } catch { + return false; + } + if (!weakSetHas(renderAttempts, attempt) || typeof prepareIframe !== 'function') return false; + + let exactDocumentOrigin: boolean; + try { + exactDocumentOrigin = + !!directAdmDocument && + typeof directAdmOwnerDocumentGetter === 'function' && + reflectApplyIntrinsic(directAdmOwnerDocumentGetter, container, []) === directAdmDocument && + directAdmDocument.defaultView?.location.origin === publisherOrigin; + } catch { + exactDocumentOrigin = false; + } + if (!exactDocumentOrigin) { + attempt.fail('winner_not_renderable'); + return false; + } + + const source = readDirectAdmSource(attempt.renderSource); + if (!source) { + attempt.fail('winner_not_renderable'); + return false; + } + if (!attempt.beginDirect()) return false; + + let activeHandle: DirectAdmIframeHandle | undefined; + let activateHandleMethod: DirectAdmIframeHandle['activate'] | undefined; + let appendHandleMethod: DirectAdmIframeHandle['append'] | undefined; + let commitHandleMethod: DirectAdmIframeHandle['commit'] | undefined; + let currentHandleMethod: DirectAdmIframeHandle['current'] | undefined; + let disposeHandleMethod: DirectAdmIframeHandle['dispose'] | undefined; + let handleDisposed = false; + let artifactOwnedByAttempt = false; + const disposeHandle = (): void => { + if (handleDisposed) return; + handleDisposed = true; + if (!activeHandle || typeof disposeHandleMethod !== 'function') return; + try { + reflectApplyIntrinsic(disposeHandleMethod, activeHandle, []); + } catch { + // The attempt remains terminal even if an injected cleanup boundary is hostile. + } + }; + const currentHandle = (): boolean => { + if (!activeHandle || typeof currentHandleMethod !== 'function') return false; + try { + return reflectApplyIntrinsic(currentHandleMethod, activeHandle, []) === true; + } catch { + return false; + } + }; + const failAttempt = (reason: RenderFailureReason): void => { + try { + attempt.fail(reason); + } catch { + if (artifactOwnedByAttempt) disposeHandle(); + } + }; + const fail = (reason: RenderFailureReason): false => { + if (!artifactOwnedByAttempt) disposeHandle(); + failAttempt(reason); + return false; + }; + try { + activeHandle = prepareIframe({ + adm: source.adm, + container, + height: source.height, + onError: () => { + if (artifactOwnedByAttempt) failAttempt('adm_document_no_load'); + }, + onLoad: () => { + if (!artifactOwnedByAttempt || !currentHandle()) { + if (artifactOwnedByAttempt) failAttempt('adm_document_no_load'); + return; + } + let accepted = false; + try { + accepted = attempt.accept() === true; + } catch { + failAttempt('internal_error'); + } + if (!accepted || !activeHandle || typeof commitHandleMethod !== 'function') return; + try { + reflectApplyIntrinsic(commitHandleMethod, activeHandle, []); + } catch { + // Terminal acceptance is already authoritative; cleanup cannot be replayed here. + } + }, + width: source.width, + }); + } catch { + return fail('adm_document_no_load'); + } + const handle = activeHandle; + if (!handle) return fail('adm_document_no_load'); + try { + disposeHandleMethod = handle.dispose; + appendHandleMethod = handle.append; + currentHandleMethod = handle.current; + activateHandleMethod = handle.activate; + commitHandleMethod = handle.commit; + if ( + typeof disposeHandleMethod !== 'function' || + typeof appendHandleMethod !== 'function' || + typeof currentHandleMethod !== 'function' || + typeof activateHandleMethod !== 'function' || + typeof commitHandleMethod !== 'function' + ) { + return fail('adm_document_no_load'); + } + } catch { + return fail('adm_document_no_load'); + } + + const artifact = frozen({ + kind: 'direct_iframe', + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: attempt.navigationGeneration, + dispose: disposeHandle, + }); + try { + if (reflectApplyIntrinsic(appendHandleMethod, handle, []) !== true) { + return fail('adm_document_no_load'); + } + } catch { + return fail('adm_document_no_load'); + } + try { + if (!attempt.beginAdm(artifact)) return fail('internal_error'); + } catch { + return fail('internal_error'); + } + artifactOwnedByAttempt = true; + let state: RenderAttemptState; + try { + state = attempt.snapshot().state; + } catch { + return fail('internal_error'); + } + if (state !== 'waiting_for_adm') return false; + if (!currentHandle()) return fail('adm_document_no_load'); + try { + if (reflectApplyIntrinsic(activateHandleMethod, handle, []) !== true) { + return fail('adm_document_no_load'); + } + } catch { + return fail('adm_document_no_load'); + } + try { + state = attempt.snapshot().state; + } catch { + return fail('internal_error'); + } + return state === 'waiting_for_adm' || state === 'accepted'; +} + interface RendererNonceBinding { readonly nonce: string; readonly attempt: RenderAttempt; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 9190244a3..d3090859d 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -372,6 +372,7 @@ describe('browser composition', () => { 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?.currentNavigation?.interfaces).toBe(session?.interfaces); expect(session?.currentNavigation?.currentAuctionProjection).toEqual(projection); expect(Object.isFrozen(session?.currentNavigation?.currentAuctionProjection)).toBe(true); 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/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index bd6900eb7..4e68cbefb 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -2,6 +2,7 @@ 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'; @@ -16,7 +17,10 @@ import { createRenderAttempt, createRendererNonceRegistry, createSlotOperation, + renderDirectAdmAttempt, type CommittedRenderArtifact, + type DirectAdmIframeConstructor, + type DirectAdmIframeHandle, type RenderAttempt, type RenderAttemptState, type SlotOperation, @@ -1915,6 +1919,367 @@ function slotOperation(options: SlotOperationOptions): SlotOperation { 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(); From 081cc723dce70d3db88268e8edca96d3291423f8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:09:22 -0700 Subject: [PATCH 047/194] fix(aps): align proxy and projection contracts --- .../src/integrations/aps.rs | 8 ++--- crates/trusted-server-core/src/publisher.rs | 36 ++++++++++++++++++- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 9fffe1b38..1b188e039 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -59,9 +59,8 @@ const MAX_LANGUAGE_BYTES: usize = 8; const MAX_PAGE_URL_BYTES: usize = 8192; const MAX_RENDER_ENVELOPE_BYTES: usize = 256 * 1024; #[cfg(any(test, feature = "test-utils"))] -// Reserve downstream response/finalization overhead inside the externally -// observed five-second dispatch-to-final-byte ceiling. -const APS_RUNNER_TOTAL_TIMEOUT: Duration = Duration::from_millis(4_500); +// Exact transport window from dispatch through the final upstream byte. +const APS_RUNNER_TOTAL_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(any(test, feature = "test-utils"))] /// Maximum wait for the APS runner response headers. pub const APS_RUNNER_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(4); @@ -3686,10 +3685,11 @@ mod tests { )]] ); assert_eq!(stub.recorded_request_bodies(), vec![Vec::::new()]); + assert_eq!(APS_RUNNER_TOTAL_TIMEOUT, Duration::from_secs(5)); assert_eq!( stub.recorded_raw_proxy_policies(), vec![RawProxyPolicyV1 { - total_timeout: Duration::from_millis(4_500), + total_timeout: APS_RUNNER_TOTAL_TIMEOUT, first_byte_timeout: Duration::from_secs(4), blocking_read_timeout: Duration::from_millis(250), max_response_bytes: APS_RUNNER_MAX_RESPONSE_BYTES, diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 5299a936d..54dc8bd22 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3375,7 +3375,7 @@ pub(crate) mod coordinated_cutover_v1 { { Some(source.clone()) } - (None, Some(raw_creative), _) => { + (None, Some(raw_creative), None) => { let priced = crate::creative::expand_auction_price_macro( raw_creative, bid.price @@ -4709,6 +4709,40 @@ mod tests { ); } + #[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(), + }; + + let canonical = coordinated_cutover_v1::build_browser_auction_projection_v1( + &result, + PriceGranularity::Dense, + &Settings::default(), + "https://publisher.example", + Some(&policy), + &ScriptedIdentityGenerator::new([vec![8; 16]]), + ) + .expect("ambiguous source should remain an explicit winner 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 invalid_targeting_is_rejected_without_truncation() { let mut bid = tagged_adm_bid("slot-1", "AAAAAAAAAAAA", 1.5); From c60eaf9074e76f5d4d7d6c1ec8db487f278183b0 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:13:03 -0700 Subject: [PATCH 048/194] ci(tsjs): enforce bundle budgets --- .github/workflows/test.yml | 3 ++ crates/trusted-server-js/lib/build-all.mjs | 2 +- crates/trusted-server-js/lib/package.json | 1 + .../lib/test/build/release-v1.test.mjs | 31 +++++++++++++++++++ .../performance/aps-tsjs-prechange.json | 11 ++++--- 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e35b59dc..2b1f09d59 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -247,6 +247,9 @@ jobs: - name: Build bundle run: npm run build + - name: Enforce bundle budgets + run: npm run check:bundle + - name: Typecheck full TSJS package run: npm run typecheck diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index bf40cf41c..d28d4c264 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -31,7 +31,7 @@ const metricsFile = 'tsjs-build-metrics-v1.json'; const releaseFile = 'tsjs-release-v1.json'; const fallbackFile = 'gpt-bootstrap-fallback.js'; -const REFERENCE_INTEGRATIONS = ['creative', 'gpt', 'prebid']; +const REFERENCE_INTEGRATIONS = ['creative', 'gpt', 'prebid', 'datadome']; function compress(bytes) { return { diff --git a/crates/trusted-server-js/lib/package.json b/crates/trusted-server-js/lib/package.json index 1e42d7b78..ddde2d2d7 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -10,6 +10,7 @@ "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:bundle": "node scripts/check-bundle-budgets.mjs", "dev": "vite build --watch", "test": "vitest run", "posttest": "npm run build && npm run test:release", 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 index 4194aad9a..752766679 100644 --- a/crates/trusted-server-js/lib/test/build/release-v1.test.mjs +++ b/crates/trusted-server-js/lib/test/build/release-v1.test.mjs @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import { RELEASE_SENTINEL, @@ -8,8 +11,36 @@ import { validateStampedRelease, } from '../../scripts/release-v1.mjs'; +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const libDirectory = path.resolve(testDirectory, '../..'); +const repositoryRoot = path.resolve(libDirectory, '../../..'); const bundle = (id, logical) => ({ id, bytes: Buffer.from(`${logical}${RELEASE_SENTINEL}`) }); +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') + ); + + assert.deepEqual(metrics.sets.reference.files, [ + 'tsjs-core.js', + 'tsjs-creative.js', + 'tsjs-gpt.js', + 'tsjs-prebid.js', + 'tsjs-datadome.js', + ]); +}); + +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 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(budgetStep > buildStep, 'bundle budget check must run after the TSJS build'); +}); + test('release id changes with logical bytes and bundle order', () => { const base = [bundle('core', 'a'), bundle('gpt', 'b')]; assert.notEqual(computeReleaseId(base), computeReleaseId([bundle('core', 'changed'), base[1]])); 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 index 12f8df903..282430f60 100644 --- 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 @@ -33,12 +33,13 @@ "tsjs-core.js", "tsjs-creative.js", "tsjs-gpt.js", - "tsjs-prebid.js" + "tsjs-prebid.js", + "tsjs-datadome.js" ], - "rawBytes": 107265, - "gzipBytes": 33428, - "brotliBytes": 25236, - "sha256": "8b9a440310ad358c292864dfa2e088c895c59199c2518fe9a3044236e459d19d" + "rawBytes": 113756, + "gzipBytes": 35163, + "brotliBytes": 26051, + "sha256": "232b734406d9baec8f244d5ab1501535d0296d9f1e0d87e30fc9e30b6c96d204" }, "maximal": { "files": [ From 4b93ea443033ff59013ed0517227b363bd4d9699 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:18:40 -0700 Subject: [PATCH 049/194] docs: make resilience plan execution atomic --- ...8-04-aps-tsjs-resilience-implementation.md | 353 +++++++++++++----- 1 file changed, 262 insertions(+), 91 deletions(-) diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index 6865982d9..ac67389d4 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -528,7 +528,13 @@ Every task's regression suite therefore remains green in task order. npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt/ad_init.test.ts test/integrations/prebid/index.test.ts ``` -### Task 5: Serve the static renderer and live APS runner proxy with adapter parity +### Task 5: Serve the static renderer and live APS runner proxy in three green checkpoints + +Task 5 is an umbrella only. Execute and review three independent red-to-green +commits: 5A defines the common reserved-family and raw-proxy contract, 5B implements +and attests the four adapter transports, and 5C implements the static renderer plus +its fictional browser fixture. The combined inventory below is not authorization to +collapse those checkpoints or carry unverified behavior between them. **Files:** @@ -591,18 +597,19 @@ Every task's regression suite therefore remains green in task order. - Modify: `docs/guide/getting-started.md` - Modify: `docs/guide/testing.md` -- [ ] **Step 1: Write failing route and exact renderer-policy tests.** +#### Task 5A: Define and test the common reserved-route and raw-proxy contract + +- [ ] **Step A1: Write failing reserved-family and raw-proxy contract tests.** - Cover enabled `GET /integrations/aps/renderer/v1` and - `GET /integrations/aps/runner.js`; APS-disabled local `404 no-store`; local - negative `404 no-store` for `/integrations/aps/runner/v1.js`, unknown renderer - versions, and malformed family paths; `405` plus `Allow: GET`; and proof that no - reserved path reaches publisher auth, EC, or fallback. Assert renderer body bytes, - the exact ordered sandbox tokens, the exact CSP from spec §3.6, exact content type, - immutable cache policy, `nosniff`, and referrer policy. Assert the deliberate - absence of `X-Frame-Options` and CSP `frame-ancestors`. + Cover enabled `GET /integrations/aps/runner.js`; APS-disabled local + `404 no-store`; negative `/integrations/aps/runner/v1.js` and malformed family + paths; `405` plus `Allow: GET`; and proof that no reserved path reaches publisher + auth, EC, or fallback. At the common platform boundary, assert exact upstream + target/request evidence, the five-second dispatch-through-final-byte deadline, + cancellation, body cap, closed response grammar, and replacement headers. Static + renderer bytes and policy remain Task 5C. -- [ ] **Step 2: Run the new focused tests and prove they fail.** +- [ ] **Step A2: Run the new focused tests and prove they fail.** ```bash cargo test-fastly integrations::aps @@ -611,10 +618,9 @@ Every task's regression suite therefore remains green in task order. cargo test-spin --test routes ``` - Expected: the live runner route/raw proxy policy and exact renderer headers are not - yet implemented on every adapter. + Expected: the live runner route and raw-proxy policy are not implemented. -- [ ] **Step 3: Define the bounded raw-proxy platform contract.** +- [ ] **Step A3: Define the bounded raw-proxy platform contract.** Add a dedicated request/response policy in `platform/http.rs` and adapter implementations that: @@ -644,7 +650,21 @@ Every task's regression suite therefore remains green in task order. defaults. If a runtime cannot supply the required evidence or cancellation behavior, APS cannot be enabled there and the release is blocked. -- [ ] **Step 4: Write and pass the complete actual-adapter proxy corpus.** +- [ ] **Step A4: Make the common contract and core fakes green, then commit before adapter** + transport work. This checkpoint contains only reserved-family dispatch, + request/response evidence types, bounded policy, core validation, and test + support; it does not claim actual-runtime parity or renderer behavior. + + ```bash + cargo test --package trusted-server-core --target aarch64-apple-darwin integrations::aps + cargo fmt --all -- --check + git add crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-core/src/integrations/mod.rs crates/trusted-server-core/src/integrations/registry.rs crates/trusted-server-core/src/platform + git commit -m "Define the bounded APS runner proxy contract" + ``` + +#### Task 5B: Implement and attest all four actual adapter transports + +- [ ] **Step B1: Write and pass the complete actual-adapter proxy corpus.** Drive each real transport boundary—including Cloudflare and Spin wasm and full Fastly routes—against a controlled fictional upstream. Cover status other than @@ -697,7 +717,7 @@ Every task's regression suite therefore remains green in task order. and transport seam for the local Fastly simulator only; it is not an APS runner pin, and no APS runner version, digest, or body enters the repository. -- [ ] **Step 5: Implement the reserved dispatcher and live proxy response.** +- [ ] **Step B2: Implement the reserved dispatcher and live proxy response.** Register the family ahead of auth/EC/fallback through one explicit test-only registry constructor used by unit tests and the dedicated integration artifacts. @@ -712,7 +732,33 @@ Every task's regression suite therefore remains green in task order. no-referrer policy. Every upstream or validation failure returns a local empty `502 no-store`, with no vendor body or descriptor/capability data in logs. -- [ ] **Step 6: Implement and test the static renderer contract.** +- [ ] **Step B3: Run and commit adapter transport parity before adding the static renderer.** + + ```bash + cargo test-fastly + cargo test-axum + cargo test-cloudflare + cargo test-spin + cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity + ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum + ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly + ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare + ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin + git add crates/trusted-server-adapter-fastly crates/trusted-server-adapter-axum crates/trusted-server-adapter-cloudflare crates/trusted-server-adapter-spin crates/trusted-server-integration-tests scripts/integration-tests-aps-runner-proxy.sh scripts/integration-tests.sh .github/workflows/integration-tests.yml + git commit -m "Implement APS runner proxy parity" + ``` + +#### Task 5C: Implement the static renderer and fictional browser fixture + +- [ ] **Step C1: Write failing static-renderer route and policy tests.** Cover + `/integrations/aps/renderer/v1`, disabled and unknown-version local + `404 no-store`, malformed family paths, `405` plus `Allow: GET`, and proof the + route cannot reach publisher auth, EC, or fallback. Assert exact body bytes, + ordered sandbox tokens, CSP, content type, immutable cache policy, `nosniff`, + referrer policy, and deliberate absence of `X-Frame-Options` and CSP + `frame-ancestors`. + +- [ ] **Step C2: Implement and test the static renderer contract.** The renderer validates/clears the fragment nonce, accepts one exact source-bound parent port, validates the descriptor and kernel-captured publisher origin, and @@ -726,7 +772,7 @@ Every task's regression suite therefore remains green in task order. from document acceptance. Mutable APS callback correctness is an accepted external trust dependency, not a fact TS can derive from script load or body inspection. -- [ ] **Step 7: Add the hermetic fictional runner fixture.** +- [ ] **Step C3: Add the hermetic fictional runner fixture.** Author a minimal local fixture that implements only the documented event and queue/resolve/reject behavior. Assert it is neither a copy, transformation, nor @@ -734,7 +780,7 @@ Every task's regression suite therefore remains green in task order. callback-silence, nested-iframe, and duplicate-callback tests. The fixture is not served as a production fallback and cannot be included in release bundles. -- [ ] **Step 8: Run the full route, transport, parity, and browser checks.** +- [ ] **Step C4: Run the full route, transport, parity, and browser checks.** ```bash cargo test-fastly @@ -751,47 +797,12 @@ Every task's regression suite therefore remains green in task order. tests/shared/aps-renderer.spec.ts --project=chromium ``` -- [ ] **Step 9: Commit the transport and renderer slice.** +- [ ] **Step C5: Commit only the static renderer and fictional browser fixture after C1-C4** + are green. Adapter transport files must already be clean from Task 5B. ```bash - git add \ - crates/trusted-server-core/src/integrations/aps.rs \ - crates/trusted-server-core/src/integrations/registry.rs \ - crates/trusted-server-core/src/platform/http.rs \ - crates/trusted-server-core/src/platform/test_support.rs \ - crates/trusted-server-core/src/platform/types.rs \ - crates/trusted-server-adapter-fastly/src/app.rs \ - crates/trusted-server-adapter-fastly/src/platform.rs \ - crates/trusted-server-adapter-fastly/Cargo.toml \ - crates/trusted-server-adapter-axum/src/app.rs \ - crates/trusted-server-adapter-axum/src/platform.rs \ - crates/trusted-server-adapter-axum/tests/routes.rs \ - crates/trusted-server-adapter-cloudflare/src/app.rs \ - crates/trusted-server-adapter-cloudflare/src/platform.rs \ - crates/trusted-server-adapter-cloudflare/Cargo.toml \ - crates/trusted-server-adapter-cloudflare/tests/routes.rs \ - crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml \ - crates/trusted-server-adapter-spin/src/app.rs \ - crates/trusted-server-adapter-spin/src/platform.rs \ - crates/trusted-server-adapter-spin/Cargo.toml \ - crates/trusted-server-adapter-spin/tests/routes.rs \ - crates/trusted-server-integration-tests/Cargo.toml \ - crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml \ - crates/trusted-server-integration-tests/fixtures/configs/viceroy-aps-runner-proxy-template.toml \ - crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs \ - crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs \ - crates/trusted-server-integration-tests/tests/common/mod.rs \ - crates/trusted-server-integration-tests/tests/environments/spin.rs \ - crates/trusted-server-integration-tests/tests/environments/mod.rs \ - crates/trusted-server-integration-tests/tests/environments/cloudflare.rs \ - crates/trusted-server-integration-tests/tests/environments/fastly.rs \ - crates/trusted-server-integration-tests/tests/parity.rs \ - crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts \ - crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js \ - scripts/integration-tests-aps-runner-proxy.sh \ - scripts/integration-tests-browser.sh \ - .github/workflows/integration-tests.yml - git commit -m "feat(aps): proxy the live creative runner safely" + git add crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js scripts/integration-tests-browser.sh docs/guide/error-reference.md docs/guide/getting-started.md docs/guide/testing.md + git commit -m "Implement the static APS renderer" ``` ### Phase 1 exit @@ -1767,7 +1778,16 @@ Every task's regression suite therefore remains green in task order. - [ ] **Step 3: Route APS and ADM winners to `RenderAttempt`/PUC bridge. Remove duplicate renderer** branches, slot expandos, local consumed-id maps, and independent refresh wrappers. -- [ ] **Step 4: Implement the owner-and-value targeting journal in `services/targeting.ts`.** +- [ ] **Step 4: Rebuild and unit-test the RCJ-GPT-04 collapsed-shell resize in the attempt-owned** + PUC success path. Resize only after the authenticated current attempt posts its + response, and only when the exact connected source iframe and its immediate + ordinary wrapper both remain collapsed to at most 1x1. Require finite positive + winning dimensions; reject anchors, unrelated frames, detached/replaced frames, + expanded dimensions, and fixed/sticky shells. Assert one guarded resize of only + those two nodes, plus inert replay, stale-attempt, navigation, and failure paths + in `test/integrations/gpt/ad_init.test.ts` and `test/services/render.test.ts`. + +- [ ] **Step 5: Implement the owner-and-value targeting journal in `services/targeting.ts`.** Keep one closure-private stack per physical GPT slot/key. Each TS write pushes a distinct frame containing its owner id, exact installed string, and predecessor value/owner—even when the string is unchanged. The GPT adapter observes the live @@ -1794,14 +1814,14 @@ Every task's regression suite therefore remains green in task order. reservation, compare-restores targeting, and settles. Prove a fast creative request always finds the store entry. -- [ ] **Step 5: Fold integration-specific script-guard mechanics onto the shared factory while** +- [ ] **Step 6: Fold integration-specific script-guard mechanics onto the shared factory while** keeping GPT configuration in its integration. Implement one runtime-owned `MutationObserver` per `NavigationSession`, 250 ms debounce, 5,000 ms monotonic window, one final boundary pass, the two-success cap, exact physical-object quarantine, and complete timer/candidate/reference disposal. Successful handoff cancels reconciliation and transfers cleanup ownership synchronously. -- [ ] **Step 6: Run the entire GPT suite, not only new files:** +- [ ] **Step 7: Run the entire GPT suite, not only new files:** ```bash npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt @@ -1881,7 +1901,33 @@ Every task's regression suite therefore remains green in task order. `prebid_selection_timeout`; navigation/auction abort clears the admitted set. No losing bid remains live for 15 minutes. -- [ ] **Step 5: Make the external artifact independently correct and pure.** Build exactly +- [ ] **Step 5: Rebuild and unit-test RCJ-PREBID-04 through one Prebid refresh policy over the** + GPT adapter. Literal, case-sensitive configured GAM-path suffixes remove only + eligible matches from the synthetic Prebid auction; missing, non-string, or + throwing `getAdUnitPath()` fails open. Clear stale TS/Prebid targeting from every + target, while the full original slot list and exact options continue to GPT. + Cover global, explicit, mixed, all-excluded, no-exclusion, and fail-open cases in + `test/integrations/prebid/index.test.ts`, `test/adapters/googletag.test.ts`, and + `test/integrations/gpt/ad_init.test.ts` before rebuilding the external artifact. + + Treat RCJ-PREBID-04 as its own named red-to-green checkpoint. Run the three focused + unit files before implementation and require the new cases to fail for refresh-list + or stale-targeting behavior; rerun them after implementation, rebuild the external + Prebid artifact, and rerun its purity/integration contract so the rebuild cannot + silently reintroduce refresh behavior: + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run \ + test/integrations/prebid/index.test.ts \ + test/adapters/googletag.test.ts \ + test/integrations/gpt/ad_init.test.ts + npm --prefix crates/trusted-server-js/lib run build:prebid-external + node --test \ + crates/trusted-server-js/lib/test/build-prebid-external.test.mjs \ + crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs + ``` + +- [ ] **Step 6: Make the external artifact independently correct and pure.** Build exactly lockfile-resolved Prebid.js 10.26.0 with no TS auction, admission, render, targeting, or refresh behavior. The first wrapper statement arms an independent 5,000 ms queue-drain watchdog before stamp inspection or module factories. It @@ -1905,7 +1951,7 @@ Every task's regression suite therefore remains green in task order. the exact `pbjs` plus stamp identities, cover late valid replacement, and prove the external artifact contains no TS behavior or `window.__tsjs_*` handshake. -- [ ] **Step 6: Run:** +- [ ] **Step 7: Run:** ```bash npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid test/integrations/aps @@ -1915,6 +1961,11 @@ Every task's regression suite therefore remains green in task order. ### Task 18: Prepare creative, diagnostics, and remaining integration modules +Task 18 is an umbrella only. Execute the detailed 18A creative, 18B diagnostics, and +18C remaining-integration sections below as three independently reviewed +red-to-green commits. The shared inventory is not authorization to stage them as one +implementation change. + **Files:** - Modify: `crates/trusted-server-js/lib/src/integrations/creative/index.ts` @@ -2001,25 +2052,22 @@ Every task's regression suite therefore remains green in task order. - Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` - Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` -- [ ] **Step 1: Add a maximal-bundle failing smoke test that loads core followed by every** - server-declared integration in manifest order and asserts one runtime, no unknown - integration id, no duplicate activation, exact reverse-order disposal, and no - leaked timer/listener/wrapper/observer after disposal. Run every module alone - and in the maximal manifest with missing globals, readiness/timeouts, malformed - config/consent/storage, matcher false positives, callback throws, startup - failure, and cross-integration isolation. +#### Task 18A: Rebuild creative as one independently green integration module -- [ ] **Step 2: Convert every remaining capability into a thin integration module.** Each +- [ ] **Step A1: Add the failing creative-only composition and lifecycle corpus.** Cover + boot validation, guard enablement combinations, automatic scans, wrapper and + observer ownership, hostile callbacks, startup rollback, disposal, and every + existing click/image/iframe/proxy-sign behavior. Run creative alone and inside + a manifest composition without modifying any other integration. + +- [ ] **Step A2: Convert only creative into a thin integration module.** Its `_registerIntegration({id,release,prepare})` call is pure registration; - `prepare(ctx)` is inert and Promise-returning; the returned `activate(ctx)` is - synchronous, registers a disposer before each reversible mutation, and uses at - most one staged `afterCommit` callback for irreversible work. Exercise all - modules through the same manifest-ordered test composition. Preserve existing - feature behavior and integration-owned matchers/configuration; shared helpers - must not broaden matching, reorder startup, stack interception, or retain work - after disposal. Do not change shipped entry-point side effects until Task 19. - -- [ ] **Step 3: Rebuild creative startup around the exact frozen `CreativeBootV1`.** Validate + `prepare(ctx)` is inert and Promise-returning; `activate(ctx)` is synchronous, + pre-registers disposal before every reversible mutation, and contributes at + most one `afterCommit` callback. Keep shipped entry-point side effects unchanged + until Task 19. + +- [ ] **Step A3: Rebuild creative startup around the exact frozen `CreativeBootV1`.** Validate the complete plain-object shape, defaults, disabled/manifest mismatch, unknown keys, accessors, prototypes, and literals before preparation. Activation installs the click guard when `clickGuard` is true and dynamic image/iframe guards when @@ -2037,7 +2085,19 @@ Every task's regression suite therefore remains green in task order. rejection of credentials, malformed values, and non-network schemes. Delete the mutable/install creative globals only in Task 22. -- [ ] **Step 4: Move render tracing to the kernel diagnostics bus and exact public surface.** +- [ ] **Step A4: Run and commit the creative slice before diagnostics or other modules.** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/creative test/composition/browser.test.ts + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run typecheck + git add crates/trusted-server-js/lib/src/integrations/creative crates/trusted-server-js/lib/test/integrations/creative crates/trusted-server-js/lib/src/composition/browser.ts crates/trusted-server-js/lib/test/composition/browser.test.ts + git commit -m "Prepare the creative integration module" + ``` + +#### Task 18B: Rebuild diagnostics transport, producers, and consumers + +- [ ] **Step B1: Move render tracing to the kernel diagnostics bus and exact public surface.** `tsjs.diagnostics.renderTrace` exposes only frozen `current()`, `history()`, and `subscribe()`. Keep current state keyed by exact slot and capped by the 256-slot navigation registry; prune on disposal. Keep document-runtime history at 200, @@ -2053,7 +2113,7 @@ Every task's regression suite therefore remains green in task order. registration-during-dispatch, callback throw isolation, and 199/200/201 overflow. Emit no `CustomEvent`, mutable trace global, or compatibility alias. -- [ ] **Step 5: Preserve GPT diagnostics through the adapter event stream.** Validate exact +- [ ] **Step B2: Preserve GPT diagnostics through the adapter event stream.** Validate exact `DiagnosticsBootV1` plus manifest activation before any listener/buffer exists. When active, core owns the six documented GPT observations before TS requests, buffers 512 raw facts until module activation, then replays and releases the @@ -2071,7 +2131,40 @@ Every task's regression suite therefore remains green in task order. storage, upload, old flag, runtime expando, or `tsjs.gptDiagnostics` alias remains after Task 22. -- [ ] **Step 6: Preserve each remaining `rc/july` integration corpus exactly.** Cover DataDome +- [ ] **Step B3: Rebuild and unit-test the server-owned `ts_console` browser-session mechanics.** + On eligible GET document navigations, accept exactly one case-sensitive + `ts_console=1|true` enable directive or `0|false` disable directive; + duplicate, conflicting, empty, or unknown values fail closed for that response. + Strip every reserved pair before publisher/origin/cookie/auction handling, + preserve all unrelated path/query/fragment data, and set or clear only the + host-only `Secure`, `HttpOnly`, `SameSite=Lax` session cookie. Assert same-origin + tab/session behavior, disabled-by-default behavior, and that frozen + `DiagnosticsBootV1.gpt.active` is the only browser-visible activation result. + +- [ ] **Step B4: Wire every diagnostics producer explicitly after its correctness commit.** + `RenderAttempt` publishes immutable render observations only after terminal or + accepted-artifact state commits; the sole GPT adapter publishes its six raw + facts only after adapter bookkeeping commits. Both use the kernel-owned bus, + never call public subscribers inline, and cannot delay, reject, retry, or mutate + rendering/GPT behavior. Test inactive zero-effects, producer throw isolation, + event ordering, enrichment replacement, buffer release, navigation disposal, + and absence of `CustomEvent`, mutable globals, or a second GPT listener set. + +- [ ] **Step B5: Run and commit diagnostics transport, producer, and consumer wiring as one** + independently green slice. + + ```bash + cargo test --package trusted-server-core --target aarch64-apple-darwin trace_cookie + npm --prefix crates/trusted-server-js/lib test -- --run test/core/trace.test.ts test/services/render.test.ts test/adapters/googletag.test.ts test/integrations/gpt_diagnostics + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run typecheck + git add crates/trusted-server-core/src/trace_cookie.rs crates/trusted-server-core/src/integrations/gpt_diagnostics.rs crates/trusted-server-js/lib/src/core/trace.ts crates/trusted-server-js/lib/src/services crates/trusted-server-js/lib/src/adapters/googletag.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics crates/trusted-server-js/lib/test/core/trace.test.ts crates/trusted-server-js/lib/test/services/render.test.ts crates/trusted-server-js/lib/test/adapters/googletag.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics + git commit -m "Rebuild bounded runtime diagnostics" + ``` + +#### Task 18C: Migrate the remaining integrations and maximal manifest + +- [ ] **Step C1: Preserve each remaining `rc/july` integration corpus exactly.** Cover DataDome script/preload path rewriting; Didomi absolute SDK path without config clobber; GTM script/preload and GA beacon/fetch rewriting; Lockr bounded readiness and API host; Osano USP/GPP/TCF marker ownership and lifecycle; Permutive bounded @@ -2084,14 +2177,29 @@ Every task's regression suite therefore remains green in task order. provider survives failed activation or module/runtime disposal, and SPA navigation does not register a duplicate. -- [ ] **Step 7: Generate and test the prospective manifest member list/order from the exact** +- [ ] **Step C2: Convert only the remaining integrations into thin modules.** Each + `_registerIntegration({id,release,prepare})` call is pure registration; + `prepare(ctx)` is inert and Promise-returning; `activate(ctx)` is synchronous, + pre-registers disposal before reversible mutation, and contributes at most one + `afterCommit`. Shared helpers must preserve each integration's exact matcher, + startup order, failure isolation, and disposal semantics. + +- [ ] **Step C3: Add the maximal-bundle failing smoke test.** Load core followed by every + server-declared integration in manifest order and assert one runtime, no unknown + id, no duplicate activation, exact reverse-order disposal, and no leaked timer, + listener, wrapper, observer, context provider, or queued continuation. Run each + module alone and in the maximal manifest with missing globals, timeout, malformed + config/consent/storage, matcher false positives, callback throws, startup + failure, and cross-integration isolation. + +- [ ] **Step C4: Generate and test the prospective manifest member list/order from the exact** enabled bundle list. Embed the same release id in core and every integration IIFE. Add failures for integration before core, unknown/missing/duplicate member, malformed/unsorted/oversized manifest, wrong release, preparation or activation failure, duplicate `afterCommit`, and the 16-member/10-second transaction limits. Production manifest emission starts only in Task 19. -- [ ] **Step 8: Run:** +- [ ] **Step C5: Run the complete remaining-integration and maximal-manifest gate:** ```bash npm --prefix crates/trusted-server-js/lib test @@ -2101,6 +2209,16 @@ Every task's regression suite therefore remains green in task order. cargo test-fastly publisher ``` +- [ ] **Step C6: Commit the remaining integrations only after C1-C5 are green.** Stage the + remaining integration directories, their shared helpers/tests, composition, + build manifest, and exact Rust config emitters; do not fold creative or + diagnostics changes from Tasks 18A/18B into this commit. + + ```bash + git add crates/trusted-server-js/lib/src/integrations crates/trusted-server-js/lib/test/integrations crates/trusted-server-js/lib/src/shared crates/trusted-server-js/lib/test/shared crates/trusted-server-js/lib/src/composition/browser.ts crates/trusted-server-js/lib/test/composition/browser.test.ts crates/trusted-server-js/lib/build-all.mjs crates/trusted-server-core/src/integrations + git commit -m "Prepare the remaining integration modules" + ``` + ### Task 19: Complete lifecycle behavior and perform the coordinated production switch **Files:** @@ -2203,7 +2321,47 @@ Every task's regression suite therefore remains green in task order. - [ ] **Step 4: Test already-loaded-page limits honestly: configuration changes reach a page only** through an existing response path; do not add polling, push, or event ingestion. -- [ ] **Step 5: Atomically activate the new production surface in one task and one commit:** +- [ ] **Step 5: Complete the pre-switch checklist with no production-wiring changes staged.** + The atomic switch is allowed to flip wiring only after every behavior suite + below is already green against the test-only composition and prospective + routes/artifacts: + - render attempt, direct APS, direct ADM, bounded cache, PUC claim/channel, artifact + store, reservations, slots, targeting, projections, auction batching, context, + navigation sessions, runtime transaction, queue handoff, and integration registry; + - GPT including RCJ-GPT-04, Prebid including RCJ-PREBID-04 and the rebuilt pure + 10.26.0 artifact, APS, creative, render trace, GPT diagnostics/`ts_console`, and + every remaining integration alone and in the maximal manifest; + - generated release/fallback/absence contracts, architecture/lint/typecheck, all + Rust projection/config/route tests, adapter parity, and the four actual-adapter + runner-proxy corpora; and + - old-surface rejection plus new-surface fixture tests, proving the cutover commit + contains no new behavior implementation or test repair. + + Install the real performance marks before this checklist closes. Execute + `performance.mark('tsjs:bids-script')` in the actual server-emitted bids/projection + boot script, and execute `performance.mark('tsjs:first-display')` exactly once at + the first authoritative GPT display call in the real adapter path. The browser + performance fixture must measure those marks with + `performance.measure('tsjs:boot-to-first-display', 'tsjs:bids-script', 'tsjs:first-display')`; + `window.__tsjsPerf` remains baseline-capture scaffolding and cannot satisfy the + post-switch gate. + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run \ + test/services test/kernel test/adapters test/core + npm --prefix crates/trusted-server-js/lib test -- --run \ + test/integrations test/composition test/build + npm --prefix crates/trusted-server-js/lib run build + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run typecheck + cargo test-fastly + cargo test-axum + cargo test-cloudflare + cargo test-spin + cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity + ``` + +- [ ] **Step 6: Atomically activate the new production surface in one task and one commit:** - `/auction` emits/parses only the exact decision-set/tagged-source wire, and initial HTML/page-bids emit only `tsjs.boot.auctionProjection`; - the immutable initial projection seeds the first `NavigationSession`; every SPA @@ -2260,7 +2418,11 @@ Every task's regression suite therefore remains green in task order. services; and - render trace and GPT diagnostics commit only after correctness transitions and expose their exact bounded asynchronous frozen APIs. Creative guards auto-install - from frozen boot configuration and both-false guards have zero DOM side effects. + from frozen boot configuration and both-false guards have zero DOM side effects; + and + - the real boot/render path records the named `tsjs:bids-script` and + `tsjs:first-display` performance marks at their authoritative transitions; the + temporary `__tsjsPerf` baseline shim is not carried into the switched runtime. Before enabling the Fastly production route, run the unchanged stall/slow-drip deadline cases through a non-production Fastly Compute service and a controlled @@ -2274,7 +2436,7 @@ Every task's regression suite therefore remains green in task order. manifest, or shape autodetection. The temporarily unused server routes and old declarations are deleted in Task 22 before release. -- [ ] **Step 6: Run:** +- [ ] **Step 7: Run:** ```bash npm --prefix crates/trusted-server-js/lib test -- --run test/services test/core test/integrations/gpt @@ -2354,8 +2516,13 @@ Every task's regression suite therefore remains green in task order. 9,999/10,000/10,001 ms boundaries, duplicate `afterCommit`, 15/16 member capacity, late continuation after fallback, publisher work during startup, exact same-task rollback, full/fallback `TsjsApi` own surfaces, malformed boot, actual-Array queue - swap/retained references/native mutators/nested pushes/callback throws, and missing - main bundle after server projection; + swap/retained references/native mutators/nested pushes/callback throws, and + missing main bundle after server projection. For every fallback commit, + instrument the real browser surfaces and assert that no second runtime, + GPT/Prebid/message listener, `MessagePort`, interval/timeout, request, script, + wrapper, observer, guard, or iframe survives; dispatch late messages, timer + boundaries, and bundles afterward and prove none can revive rendering or allocate + replacement state; - navigation/projection/API: immutable initial boot versus SPA-owned replacement, stale/duplicate/malformed page-bids, exact grammar/count/UTF-8 and 8 MiB all-winner reduction, 255/256/257 combined server/programmatic slots, transactional @@ -2604,7 +2771,11 @@ Every task's regression suite therefore remains green in task order. - [ ] **Step 2: On the pinned Chromium/CI-machine/fixture, measure boot-to-first-display p90 after** five warmups and 50 samples and require ≤1.10× the Task 0 baseline. Do not rerun - selectively to turn a failed sample into a pass. + selectively to turn a failed sample into a pass. The post-switch sample reads + the real `tsjs:bids-script` and `tsjs:first-display` performance marks and the + `tsjs:boot-to-first-display` measure installed in Task 19; fail if any sample + falls back to the pre-change `window.__tsjsPerf` placeholder or lacks either + mark. - [ ] **Step 3: Through Chromium CDP, collect garbage then record retained heap after boot, first** render, refresh, and SPA navigation; gate each checkpoint at ≤1.10×. Firefox and From e059f998ef251d319820923017a52a9a6a3a8276 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:20:17 -0700 Subject: [PATCH 050/194] docs: clarify resilience contract guardrails --- .../trusted-server-core/src/auction/types.rs | 3 +++ .../generated/aps_renderer_validator_v1.js | 2 +- .../lib/eslint-rules/no-adtech-globals.js | 4 ++++ .../generated/renderer_validator_v1.ts | 2 +- .../test/fixtures/aps-renderer-v1.schema.json | 1 + ...8-04-aps-tsjs-resilience-implementation.md | 20 +++++++++++++++---- ...s-render-fix-and-tsjs-resilience-design.md | 13 ++++++++---- 7 files changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 9e03db98f..7be13d7d5 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -259,6 +259,9 @@ pub enum AuctionDropReason { 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 { diff --git a/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js b/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js index a81408244..5c8a161f1 100644 --- a/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js +++ b/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js @@ -1,5 +1,5 @@ // @generated by scripts/generate-aps-renderer-contract.mjs -// schema-sha256: e7ed370e6ccbeb30660b63ae0837dbde6ddd56e81f606a544d37b9d0b99f5d1d +// schema-sha256: 3f82e9c8d57719c29810a0ed181f4fe2779919c65605ae0cd7c61bd6d865b027 // corpus-sha256: 3aea612e3316e6df4852e80cb3aa8882ca43d842455a22ba29e63fa88291c7b9 var DESCRIPTOR_KEYS = ["aaxResponse","accountId","bidId","creativeUrl","height","tagType","type","version","width"]; var DESCRIPTOR_KEYS_WITH_CREATIVE_ID = ["aaxResponse","accountId","bidId","creativeId","creativeUrl","height","tagType","type","version","width"]; 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 index 3cf962a9e..78aff60b6 100644 --- a/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js +++ b/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js @@ -1,6 +1,10 @@ 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. + export const LEGACY_ADTECH_GLOBAL_ALLOWLIST = Object.freeze([ 'src/integrations/gpt/index.ts', 'src/integrations/gpt_diagnostics/observer.ts', 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 index 33d714185..dec409ae6 100644 --- 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 @@ -1,5 +1,5 @@ // @generated by scripts/generate-aps-renderer-contract.mjs -// schema-sha256: e7ed370e6ccbeb30660b63ae0837dbde6ddd56e81f606a544d37b9d0b99f5d1d +// schema-sha256: 3f82e9c8d57719c29810a0ed181f4fe2779919c65605ae0cd7c61bd6d865b027 // corpus-sha256: 3aea612e3316e6df4852e80cb3aa8882ca43d842455a22ba29e63fa88291c7b9 /* eslint-disable */ export type ApsRendererValidationResult = 'accepted' | 'descriptor_invalid' | 'invalid_dimensions' | 'dimensions_out_of_range'; 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 index ef605725a..4fe6c0445 100644 --- 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 @@ -1,6 +1,7 @@ { "$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, diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index ac67389d4..95437e703 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -220,6 +220,12 @@ Every task's regression suite therefore remains green in task order. `aps-tsjs-prechange.json`; later tasks may compare against it but must not regenerate it from the completed implementation. + Define the sets exactly as minimal `[core]`, reference + `[core, creative, gpt, prebid, datadome]`, and maximal core plus every built + integration. Expose the comparator as `npm run check:bundle` and run it in the + TypeScript CI job immediately after `npm run build`; a generated metrics file that + is not consumed by CI is not a gate. + Extend `scripts/integration-tests-browser.sh` with `TS_BROWSER_FRAMEWORKS=nextjs` and use `npm --prefix ... exec -- playwright` for argument-safe invocation. The script remains the clean-checkout fixture builder: @@ -622,8 +628,8 @@ collapse those checkpoints or carry unverified behavior between them. - [ ] **Step A3: Define the bounded raw-proxy platform contract.** - Add a dedicated request/response policy in `platform/http.rs` and adapter - implementations that: + Add a dedicated request/response policy in `platform/http.rs` and core test-support + contract that requires adapter implementations to: - sends only credential-free `GET` to the compile-time fixed URL `https://client.aps.amazon-adsystem.com/prebid-creative.js` with `Accept-Encoding: identity`, no forwarded browser/publisher headers, no referrer, @@ -638,7 +644,7 @@ collapse those checkpoints or carry unverified behavior between them. generation-inert late continuations; and - uses the common `APS_RUNNER_MAX_RESPONSE_BYTES = 8 MiB` cap. - Cloudflare must preserve `web_sys::Request.method()` before workers-rs conversion + Task 5B's Cloudflare implementation must preserve `web_sys::Request.method()` before workers-rs conversion and restore it at the reserved pre-router boundary because workers-rs maps extension methods such as `PROPFIND` to `GET`. It must also inspect the initial Workers headers before its generic adapter strips encoding/length; concatenated duplicates remain @@ -717,7 +723,8 @@ collapse those checkpoints or carry unverified behavior between them. and transport seam for the local Fastly simulator only; it is not an APS runner pin, and no APS runner version, digest, or body enters the repository. -- [ ] **Step B2: Implement the reserved dispatcher and live proxy response.** +- [ ] **Step B2: Implement each actual adapter transport, the reserved dispatcher, and the live** + **proxy response.** Register the family ahead of auth/EC/fallback through one explicit test-only registry constructor used by unit tests and the dedicated integration artifacts. @@ -2337,6 +2344,11 @@ implementation change. - old-surface rejection plus new-surface fixture tests, proving the cutover commit contains no new behavior implementation or test repair. + Keep the ±5% bundle-size gate wired and visible. Intermediate old-plus-new bundle + growth may remain recorded as a failing pre-switch check, but it must never be + rebased into the immutable pre-change artifact; the gate must be green after the + atomic switch and Task 22 legacy deletion, before release readiness. + Install the real performance marks before this checklist closes. Execute `performance.mark('tsjs:bids-script')` in the actual server-emitted bids/projection boot script, and execute `performance.mark('tsjs:first-display')` exactly once at diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index ec4767208..51751f2f7 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -2503,7 +2503,11 @@ subscription methods. The final schema is: ```ts type RenderTracePathV1 = 'auction' | 'ssat' | 'gam-refresh' type RenderTraceServedFromV1 = - 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid' + | 'inline' + | 'gam' + | 'debug-adm' + | 'pbs-cache' + | 'prebid' interface RenderTraceRecord { readonly slotId: string @@ -2778,9 +2782,10 @@ After that upgrade, the lockfile compiler is the authority. CI runs a checked-in `exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `noImplicitOverride`, and `useUnknownInCatchVariables`. Production bundles contain no dynamic imports. -Before implementation, CI records deterministic gzip/Brotli baselines for minimal, -reference, and maximal integration sets; each may grow at most 5% unless separately -approved. Boot-to-first-display p90 uses a pinned Chromium version, CI runner class, +Before implementation, CI records deterministic gzip/Brotli baselines for the +minimal `[core]`, reference `[core, creative, gpt, prebid, datadome]`, and maximal +all-built-integration sets; each may grow at most 5% unless separately approved. +Boot-to-first-display p90 uses a pinned Chromium version, CI runner class, fixture, warmup count, and sample count and must stay within 10% of that pre-change baseline. Retained heap uses Chromium CDP only, with forced-GC checkpoints after boot, first render, refresh, and SPA navigation, and the same 10% limit. Correctness From 1c68d9f5650c46c8c662dc86089179d13f82683b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:45:34 -0700 Subject: [PATCH 051/194] Implement bounded cache rendering --- .../lib/src/composition/browser.ts | 19 + .../lib/src/services/render.ts | 639 +++++++++++++++++- .../lib/test/composition/browser.test.ts | 1 + .../lib/test/services/render.test.ts | 589 ++++++++++++++++ 4 files changed, 1238 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 3dff7037a..e488e8093 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -39,6 +39,7 @@ import { import { createReservationService, type ReservationService } from '../services/reservations'; import { createRendererNonceRegistry, + renderDirectCacheAttempt, renderDirectAdmAttempt, type RenderAttempt, type RendererNonceRegistry, @@ -61,6 +62,7 @@ export interface BrowserServices { 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; } @@ -218,6 +220,7 @@ export function createTestBrowserRuntimeComposition( }); const rendererNonces = createRendererNonceRegistry(); const publisherOrigin = window.location.origin; + const fetchCache = globalThis.fetch; const renderDirectAdm = (attempt: RenderAttempt, container: HTMLElement): boolean => { try { return renderDirectAdmAttempt({ @@ -230,6 +233,21 @@ export function createTestBrowserRuntimeComposition( return false; } }; + const renderDirectCache = (attempt: RenderAttempt, container: HTMLElement): boolean => { + if (!cachePolicy || typeof fetchCache !== 'function') return false; + try { + return renderDirectCacheAttempt({ + attempt, + cachePolicy, + container, + fetcher: (input, init) => fetchCache(input, init), + prepareIframe: prepareAdmIframe, + publisherOrigin, + }); + } catch { + return false; + } + }; const renderDirectAps = (attempt: RenderAttempt, container: HTMLElement): boolean => { try { return renderDirectApsAttempt({ @@ -248,6 +266,7 @@ export function createTestBrowserRuntimeComposition( rendererNonces, renderDirectAdm, renderDirectAps, + renderDirectCache, slots: slotService, targeting: targetingService, }); diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index e1ad3046d..d83e81d98 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -14,6 +14,9 @@ const ATTEMPT_ID = /^a1_[A-Za-z0-9_-]{22}$/; const RENDERER_NONCE = /^n1_[A-Za-z0-9_-]{22}$/; const MAX_RENDERER_NONCES = 256; const MAX_RENDERER_NONCE_DRAWS = 8; +const MAX_CACHE_BODY_BYTES = 512 * 1024; +const MAX_CACHE_URL_BYTES = 4096; +const CACHE_ID = /^[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 reflectApplyIntrinsic = Reflect.apply; const directAdmDocument = typeof document === 'undefined' ? undefined : document; const directAdmOwnerDocumentGetter = @@ -21,6 +24,7 @@ const directAdmOwnerDocumentGetter = ? undefined : Object.getOwnPropertyDescriptor(Node.prototype, 'ownerDocument')?.get; const objectFreezeIntrinsic = Object.freeze; +const objectToStringIntrinsic = Object.prototype.toString; const arrayIncludesIntrinsic = Array.prototype.includes; const arrayPushIntrinsic = Array.prototype.push; const arraySliceIntrinsic = Array.prototype.slice; @@ -59,6 +63,10 @@ const weakSetAddIntrinsic = WeakSet.prototype.add; const weakSetHasIntrinsic = WeakSet.prototype.has; const weakSetDeleteIntrinsic = WeakSet.prototype.delete; const promiseThenIntrinsic = Promise.prototype.then; +const stringIndexOfIntrinsic = String.prototype.indexOf; +const stringSliceIntrinsic = String.prototype.slice; +const stringIntrinsic = String; +const jsonParseIntrinsic = JSON.parse; const artifactDisposals = new WeakMap(); const committedArtifactStores = new WeakSet(); const renderAttempts = new WeakSet(); @@ -72,6 +80,14 @@ function arrayPush(array: Value[], value: Value): number { return Reflect.apply(arrayPushIntrinsic, array, [value]) as number; } +function isUint8Array(value: unknown): value is Uint8Array { + return ( + (typeof value === 'object' || typeof value === 'function') && + value !== null && + reflectApplyIntrinsic(objectToStringIntrinsic, value, []) === '[object Uint8Array]' + ); +} + function arraySlice(array: Value[]): Value[] { return Reflect.apply(arraySliceIntrinsic, array, [0]) as Value[]; } @@ -321,6 +337,11 @@ export const RENDER_STATE_DEADLINES: Readonly< waiting_for_adm: frozen({ milliseconds: 5_000, reason: 'adm_document_no_load' }), }); +const CACHE_FETCH_DEADLINE = frozen({ + milliseconds: 5_000, + reason: 'cache_network_error' as const, +}); + export interface RenderAttemptOptions { readonly owner: RenderAttemptScope; readonly artifacts: CommittedArtifactStore; @@ -356,6 +377,8 @@ export interface RenderAttempt { readonly beginGamClaim: () => boolean; readonly ownerClaimed: () => boolean; readonly ownerRegistered: () => boolean; + readonly beginCacheFetch: () => boolean; + readonly cacheFetchCompleted: () => boolean; readonly beginDirect: () => boolean; readonly beginApsDocument: (artifact: CommittedRenderArtifact) => boolean; readonly beginAdm: (artifact: CommittedRenderArtifact) => boolean; @@ -375,6 +398,23 @@ export interface DirectAdmAttemptOptions { readonly publisherOrigin: string; } +interface CacheFetchReader { + readonly cancel?: () => Promise | unknown; + readonly read: () => Promise>; + readonly releaseLock?: () => void; +} + +interface CacheFetchResponse { + readonly body: Readonly<{ getReader: () => CacheFetchReader }> | null; + readonly ok: boolean; + readonly type?: Response['type']; +} + +export interface DirectCacheAttemptOptions extends DirectAdmAttemptOptions { + readonly cachePolicy: Readonly<{ version: 1; baseUrl: string }>; + readonly fetcher: (input: string, init: RequestInit) => Promise; +} + export interface DirectAdmIframeHandle { readonly frame: HTMLIFrameElement; append(): boolean; @@ -957,6 +997,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp let pendingArtifact: CommittedRenderArtifact | undefined; let admittedRenderSource: ReservationRenderSource | undefined; let admittedWinnerContext: WinnerContext | undefined; + let cacheFetchStarted = false; let deadlineHandle: unknown; let deadlineState: RenderAttemptActiveState | undefined; let settlingInternally = false; @@ -1198,8 +1239,11 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp ? settle(frozen({ outcome: 'failed', reason }), true) : false; - const armDeadline = (entered: RenderAttemptActiveState): void => { - const deadline = RENDER_STATE_DEADLINES[entered]; + const armDeadline = ( + entered: RenderAttemptActiveState, + explicitDeadline?: RenderDeadline + ): void => { + const deadline = explicitDeadline ?? RENDER_STATE_DEADLINES[entered]; if (!deadline) return; deadlineState = entered; try { @@ -1229,7 +1273,8 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp const enter = ( allowed: readonly RenderAttemptActiveState[], - next: RenderAttemptActiveState + next: RenderAttemptActiveState, + explicitDeadline?: RenderDeadline ): boolean => { if (outcome !== undefined || !ownerIsCurrent() || !allowed.includes(state as never)) { return false; @@ -1237,7 +1282,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp state = next; arrayPush(history, next); clearDeadline(); - if (outcome === undefined) armDeadline(next); + if (outcome === undefined) armDeadline(next, explicitDeadline); return true; }; @@ -1269,6 +1314,29 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp return false; }; + const beginCacheFetch = (): boolean => { + if ( + cacheFetchStarted || + outcome !== undefined || + admittedRenderSource?.type !== 'cache' || + admittedWinnerContext === undefined || + !ownerIsCurrent() + ) { + return false; + } + if (state === 'created') { + cacheFetchStarted = true; + return enter(['created'], 'rendering_direct', CACHE_FETCH_DEADLINE); + } + if (state !== 'waiting_for_insertion') return false; + cacheFetchStarted = true; + clearDeadline(); + if (outcome === undefined && state === 'waiting_for_insertion') { + armDeadline('waiting_for_insertion', CACHE_FETCH_DEADLINE); + } + return outcome === undefined && state === 'waiting_for_insertion'; + }; + const lifecycle: RenderAttempt = { id, slot, @@ -1292,8 +1360,24 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp ? enter(['waiting_for_gam_and_claim'], 'waiting_for_owner') : false, ownerRegistered: () => enter(['waiting_for_owner'], 'waiting_for_insertion'), + beginCacheFetch, + cacheFetchCompleted: () => { + if ( + admittedRenderSource?.type !== 'cache' || + !admittedWinnerContext || + outcome !== undefined || + (state !== 'rendering_direct' && state !== 'waiting_for_insertion') || + deadlineState !== state || + !ownerIsCurrent() + ) { + return false; + } + clearDeadline(); + return true; + }, beginDirect: () => - admittedRenderSource && admittedWinnerContext + (admittedRenderSource?.type === 'aps' || admittedRenderSource?.type === 'adm') && + admittedWinnerContext ? enter(['created'], 'rendering_direct') : false, beginApsDocument: (artifact) => @@ -1435,6 +1519,337 @@ type DirectAdmSource = Readonly<{ width: number; }>; +type DirectCacheSource = Readonly<{ + cacheId: string; + fetchUrl: string; + height: number; + type: 'cache'; + version: 1; + width: number; +}>; + +type CacheBodyResult = + | Readonly<{ ok: true; text: string }> + | Readonly<{ ok: false; reason: 'cache_network_error' | 'cache_invalid_response' }>; + +function exactFrozenDataRecord( + value: unknown, + expectedNames: readonly string[] +): Record | 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).sort(); + if (names.length !== expectedNames.length) return undefined; + const fields = Object.create(null) as Record; + for (let index = 0; index < expectedNames.length; index += 1) { + const name = expectedNames[index]; + if (!name || names[index] !== name) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if ( + !descriptor || + !('value' in descriptor) || + descriptor.enumerable !== true || + descriptor.configurable !== false || + descriptor.writable !== false + ) { + return undefined; + } + fields[name] = descriptor.value; + } + return fields; + } catch { + return undefined; + } +} + +function readCachePolicyBase(value: unknown): URL | undefined { + try { + const fields = exactFrozenDataRecord(value, ['baseUrl', 'version']); + const baseUrl = fields?.['baseUrl']; + if ( + !fields || + fields['version'] !== 1 || + typeof baseUrl !== 'string' || + baseUrl.length === 0 || + new TextEncoder().encode(baseUrl).byteLength > MAX_CACHE_URL_BYTES + ) { + return undefined; + } + for (let index = 0; index < baseUrl.length; index += 1) { + const code = baseUrl.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return undefined; + if (code >= 0xd800 && code <= 0xdbff) { + const next = baseUrl.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return undefined; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return undefined; + } + } + const base = new URL(baseUrl); + if ( + base.protocol !== 'https:' || + base.hostname === '' || + base.username !== '' || + base.password !== '' || + base.search !== '' || + base.hash !== '' || + base.pathname === '/' + ) { + return undefined; + } + return base; + } catch { + return undefined; + } +} + +function readDirectCacheSource( + value: unknown, + cachePolicy: unknown +): DirectCacheSource | undefined { + try { + const base = readCachePolicyBase(cachePolicy); + if (!base) return undefined; + const fields = exactFrozenDataRecord(value, [ + 'cacheId', + 'fetchUrl', + 'height', + 'type', + 'version', + 'width', + ]); + if ( + !fields || + fields['type'] !== 'cache' || + fields['version'] !== 1 || + typeof fields['cacheId'] !== 'string' || + !CACHE_ID.test(fields['cacheId']) || + typeof fields['fetchUrl'] !== 'string' || + fields['fetchUrl'].length === 0 || + new TextEncoder().encode(fields['fetchUrl']).byteLength > MAX_CACHE_URL_BYTES || + typeof fields['width'] !== 'number' || + !Number.isInteger(fields['width']) || + fields['width'] < 1 || + fields['width'] > 4096 || + typeof fields['height'] !== 'number' || + !Number.isInteger(fields['height']) || + fields['height'] < 1 || + fields['height'] > 4096 + ) { + return undefined; + } + const sourceFetchUrl = fields['fetchUrl'] as string; + for (let index = 0; index < sourceFetchUrl.length; index += 1) { + const code = sourceFetchUrl.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return undefined; + if (code >= 0xd800 && code <= 0xdbff) { + const next = sourceFetchUrl.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return undefined; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return undefined; + } + } + const fetchUrl = new URL(sourceFetchUrl); + const expected = new URL(base.href); + expected.search = `?uuid=${encodeURIComponent(fields['cacheId'])}`; + if ( + fetchUrl.protocol !== 'https:' || + fetchUrl.username !== '' || + fetchUrl.password !== '' || + fetchUrl.hash !== '' || + fetchUrl.origin !== base.origin || + fetchUrl.port !== base.port || + fetchUrl.pathname !== base.pathname || + [...fetchUrl.searchParams.keys()].length !== 1 || + fetchUrl.searchParams.get('uuid') !== fields['cacheId'] || + fetchUrl.search !== `?uuid=${encodeURIComponent(fields['cacheId'])}` || + fetchUrl.href !== fields['fetchUrl'] || + fetchUrl.href !== expected.href + ) { + return undefined; + } + return value as DirectCacheSource; + } catch { + return undefined; + } +} + +function readSelectedCpm(value: unknown): number | undefined { + const fields = exactFrozenDataRecord(value, ['selectedCpm']); + const selectedCpm = fields?.['selectedCpm']; + return typeof selectedCpm === 'number' && Number.isFinite(selectedCpm) && selectedCpm >= 0 + ? selectedCpm + : undefined; +} + +function expandAuctionPrice(adm: string, selectedCpm: number): string { + const token = '${AUCTION_PRICE}'; + const replacement = stringIntrinsic(selectedCpm); + let cursor = 0; + let output = ''; + while (true) { + const next = reflectApplyIntrinsic(stringIndexOfIntrinsic, adm, [token, cursor]) as number; + if (next < 0) { + return output + (reflectApplyIntrinsic(stringSliceIntrinsic, adm, [cursor]) as string); + } + output += + (reflectApplyIntrinsic(stringSliceIntrinsic, adm, [cursor, next]) as string) + replacement; + cursor = next + token.length; + } +} + +function parseCacheAdm( + text: string, + source: DirectCacheSource, + selectedCpm: number +): DirectAdmSource | undefined { + try { + const value = reflectApplyIntrinsic(jsonParseIntrinsic, JSON, [text]) as unknown; + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return undefined; + } + const record = value as Record; + const names = Object.getOwnPropertyNames(record); + for (let index = 0; index < names.length; index += 1) { + const name = names[index]; + if (!name) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(record, name); + if (!descriptor || !('value' in descriptor) || descriptor.enumerable !== true) { + return undefined; + } + } + const hasOwn = (name: string): boolean => Object.prototype.hasOwnProperty.call(record, name); + if (hasOwn('width') || hasOwn('height')) return undefined; + const admDescriptor = Object.getOwnPropertyDescriptor(record, 'adm'); + if ( + !admDescriptor || + !('value' in admDescriptor) || + typeof admDescriptor.value !== 'string' || + admDescriptor.value.trim().length === 0 || + new TextEncoder().encode(admDescriptor.value).byteLength > MAX_CACHE_BODY_BYTES + ) { + return undefined; + } + const hasWidth = hasOwn('w'); + const hasHeight = hasOwn('h'); + if (hasWidth !== hasHeight) return undefined; + if ( + hasWidth && + (typeof record['w'] !== 'number' || + !Number.isInteger(record['w']) || + record['w'] < 1 || + record['w'] > 4096 || + record['w'] !== source.width || + typeof record['h'] !== 'number' || + !Number.isInteger(record['h']) || + record['h'] < 1 || + record['h'] > 4096 || + record['h'] !== source.height) + ) { + return undefined; + } + if ( + hasOwn('price') && + (typeof record['price'] !== 'number' || + !Number.isFinite(record['price']) || + record['price'] < 0) + ) { + return undefined; + } + const adm = expandAuctionPrice(admDescriptor.value, selectedCpm); + if (new TextEncoder().encode(adm).byteLength > MAX_CACHE_BODY_BYTES) return undefined; + return frozen({ + adm, + height: source.height, + type: 'adm', + version: 1, + width: source.width, + }); + } catch { + return undefined; + } +} + +async function readCacheBody(response: CacheFetchResponse): Promise { + let reader: CacheFetchReader | undefined; + let cancel: CacheFetchReader['cancel']; + let releaseLock: (() => void) | undefined; + try { + if ( + response.type === 'error' || + response.type === 'opaque' || + response.type === 'opaqueredirect' + ) { + return frozen({ ok: false, reason: 'cache_network_error' }); + } + if (!response.ok) return frozen({ ok: false, reason: 'cache_invalid_response' }); + if (!response.body) return frozen({ ok: true, text: '' }); + reader = response.body.getReader(); + cancel = reader.cancel; + releaseLock = reader.releaseLock; + if (typeof reader.read !== 'function' || typeof cancel !== 'function') { + return frozen({ ok: false, reason: 'cache_network_error' }); + } + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const step = await reader.read(); + if (step.done) break; + if (!isUint8Array(step.value)) { + return frozen({ ok: false, reason: 'cache_network_error' }); + } + total += step.value.byteLength; + if (total > MAX_CACHE_BODY_BYTES) { + try { + await cancel.call(reader); + } catch { + // The byte limit is authoritative even if stream cancellation is hostile. + } + return frozen({ ok: false, reason: 'cache_invalid_response' }); + } + arrayPush(chunks, step.value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (let index = 0; index < chunks.length; index += 1) { + const chunk = chunks[index]; + if (!chunk) return frozen({ ok: false, reason: 'cache_network_error' }); + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return frozen({ + ok: true, + text: new TextDecoder('utf-8', { fatal: true }).decode(bytes), + }); + } catch { + return frozen({ ok: false, reason: 'cache_network_error' }); + } finally { + if (reader && typeof releaseLock === 'function') { + try { + releaseLock.call(reader); + } catch { + // The bounded result remains authoritative if stream lock release is hostile. + } + } + } +} + function readDirectAdmSource(value: unknown): DirectAdmSource | undefined { try { if ( @@ -1489,8 +1904,10 @@ function readDirectAdmSource(value: unknown): DirectAdmSource | undefined { } } -/** Drive one admitted direct ADM attempt through the shared iframe constructor. */ -export function renderDirectAdmAttempt(options: DirectAdmAttemptOptions): boolean { +function renderAdmAttempt( + options: DirectAdmAttemptOptions, + admittedCacheAdm?: DirectAdmSource +): boolean { let attempt: RenderAttempt; let container: HTMLElement; let prepareIframe: DirectAdmIframeConstructor; @@ -1520,12 +1937,22 @@ export function renderDirectAdmAttempt(options: DirectAdmAttemptOptions): boolea return false; } - const source = readDirectAdmSource(attempt.renderSource); + const source = admittedCacheAdm ?? readDirectAdmSource(attempt.renderSource); if (!source) { attempt.fail('winner_not_renderable'); return false; } - if (!attempt.beginDirect()) return false; + if (admittedCacheAdm === undefined && !attempt.beginDirect()) return false; + let artifactKind: CommittedRenderArtifact['kind']; + try { + const pathState = attempt.snapshot().state; + if (pathState === 'waiting_for_insertion') artifactKind = 'puc'; + else if (pathState === 'rendering_direct') artifactKind = 'direct_iframe'; + else return false; + } catch { + attempt.fail('internal_error'); + return false; + } let activeHandle: DirectAdmIframeHandle | undefined; let activateHandleMethod: DirectAdmIframeHandle['activate'] | undefined; @@ -1618,7 +2045,7 @@ export function renderDirectAdmAttempt(options: DirectAdmAttemptOptions): boolea } const artifact = frozen({ - kind: 'direct_iframe', + kind: artifactKind, attemptId: attempt.id, slot: attempt.slot, navigationGeneration: attempt.navigationGeneration, @@ -1660,6 +2087,198 @@ export function renderDirectAdmAttempt(options: DirectAdmAttemptOptions): boolea return state === 'waiting_for_adm' || state === 'accepted'; } +/** Drive one admitted direct ADM attempt through the shared iframe constructor. */ +export function renderDirectAdmAttempt(options: DirectAdmAttemptOptions): boolean { + return renderAdmAttempt(options); +} + +/** Fetch one admitted cache source, then enter the exact shared direct-ADM lifecycle. */ +export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): boolean { + let attempt: RenderAttempt; + let cachePolicy: DirectCacheAttemptOptions['cachePolicy']; + let container: HTMLElement; + let fetchCache: DirectCacheAttemptOptions['fetcher']; + let prepareIframe: DirectAdmIframeConstructor; + let publisherOrigin: string; + try { + attempt = options.attempt; + cachePolicy = options.cachePolicy; + container = options.container; + fetchCache = options.fetcher; + prepareIframe = options.prepareIframe; + publisherOrigin = options.publisherOrigin; + } catch { + return false; + } + if ( + !weakSetHas(renderAttempts, attempt) || + typeof fetchCache !== 'function' || + typeof prepareIframe !== 'function' + ) { + return false; + } + + let exactDocumentOrigin: boolean; + try { + exactDocumentOrigin = + !!directAdmDocument && + typeof directAdmOwnerDocumentGetter === 'function' && + reflectApplyIntrinsic(directAdmOwnerDocumentGetter, container, []) === directAdmDocument && + directAdmDocument.defaultView?.location.origin === publisherOrigin; + } catch { + exactDocumentOrigin = false; + } + if (!exactDocumentOrigin) { + attempt.fail('winner_not_renderable'); + return false; + } + + const source = readDirectCacheSource(attempt.renderSource, cachePolicy); + const winnerContext = attempt.winnerContext; + const selectedCpm = readSelectedCpm(winnerContext); + if (!source || selectedCpm === undefined) { + attempt.fail('descriptor_invalid'); + return false; + } + if (!attempt.beginCacheFetch()) return false; + + let controller: AbortController; + try { + controller = new AbortController(); + } catch { + attempt.fail('cache_network_error'); + return false; + } + + let pending = true; + const abortFetch = (): void => { + if (controller.signal.aborted) return; + try { + controller.abort(); + } catch { + // Abort is best-effort after the attempt has already settled. + } + }; + const failCache = (reason: RenderFailureReason): void => { + if (!pending) return; + pending = false; + abortFetch(); + try { + attempt.fail(reason); + } catch { + // A hostile attempt boundary cannot replay the already-closed cache phase. + } + }; + if ( + !attempt.onSettled(() => { + pending = false; + abortFetch(); + }) + ) { + failCache('cache_network_error'); + return false; + } + if (!pending) return false; + + let responsePromise: Promise; + try { + responsePromise = reflectApplyIntrinsic(fetchCache, undefined, [ + source.fetchUrl, + { + credentials: 'omit', + method: 'GET', + mode: 'cors', + redirect: 'error', + referrer: '', + referrerPolicy: 'no-referrer', + signal: controller.signal, + } satisfies RequestInit, + ]) as Promise; + } catch { + failCache('cache_network_error'); + return false; + } + + const complete = async (): Promise => { + let fetched: unknown; + try { + fetched = await responsePromise; + } catch { + failCache('cache_network_error'); + return; + } + if (!pending) return; + let response: CacheFetchResponse; + let responseOk: boolean; + let responseType: Response['type'] | undefined; + try { + if ((typeof fetched !== 'object' && typeof fetched !== 'function') || fetched === null) { + throw new TypeError('invalid cache response'); + } + response = fetched as CacheFetchResponse; + responseOk = response.ok; + responseType = response.type; + if (typeof responseOk !== 'boolean') throw new TypeError('invalid cache status'); + } catch { + failCache('cache_network_error'); + return; + } + if ( + responseType === 'error' || + responseType === 'opaque' || + responseType === 'opaqueredirect' + ) { + failCache('cache_network_error'); + return; + } + if (!responseOk) { + failCache('cache_http_error'); + return; + } + const body = await readCacheBody(response); + if (!pending) return; + if (!body.ok) { + failCache(body.reason); + return; + } + if (!attempt.cacheFetchCompleted()) { + failCache('cache_network_error'); + return; + } + const admSource = parseCacheAdm(body.text, source, selectedCpm); + if (!admSource) { + failCache('cache_invalid_response'); + return; + } + if (attempt.renderSource !== source || attempt.winnerContext !== winnerContext) { + failCache('cache_invalid_response'); + return; + } + pending = false; + try { + if ( + !renderAdmAttempt({ attempt, container, prepareIframe, publisherOrigin }, admSource) && + attempt.snapshot().outcome === undefined + ) { + attempt.fail('internal_error'); + } + } catch { + attempt.fail('internal_error'); + } + }; + const completion = complete(); + try { + reflectApplyIntrinsic(promiseThenIntrinsic, completion, [ + ignoreAsyncDisposal, + ignoreAsyncDisposal, + ]); + } catch { + failCache('cache_network_error'); + return false; + } + return true; +} + interface RendererNonceBinding { readonly nonce: string; readonly attempt: RenderAttempt; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index d3090859d..7152c697e 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -373,6 +373,7 @@ describe('browser composition', () => { 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); diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index 4e68cbefb..ce529a5ad 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -17,6 +17,7 @@ import { createRenderAttempt, createRendererNonceRegistry, createSlotOperation, + renderDirectCacheAttempt, renderDirectAdmAttempt, type CommittedRenderArtifact, type DirectAdmIframeConstructor, @@ -79,9 +80,23 @@ const DIRECT_APS_SOURCE = Object.freeze({ }); 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, +}); 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; @@ -1912,6 +1927,580 @@ function claimed( 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('accepts a same-origin basic response because request mode enforces CORS', async () => { + 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: 'basic' }); + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container, + fetcher: async () => basicResponse, + prepareIframe: prepareAdmIframe, + 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('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('renders a delayed owner-controlled cache claim as one PUC artifact', 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')!; + + 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: 'waiting_for_insertion', + }); + expect(fetchCache).toHaveBeenCalledOnce(); + expect(resolveFetch).toBeTypeOf('function'); + resolveFetch?.( + corsResponse(JSON.stringify({ adm: '
${AUCTION_PRICE}
', price: 9000 })) + ); + const frame = await insertedCacheFrame(container, render); + expect(frame.srcdoc).toContain('
1
'); + expect(frame.srcdoc).not.toContain('9000'); + + if (render.snapshot().outcome === undefined) frame.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(artifacts.current('fictional-slot')).toMatchObject({ + attemptId: render.id, + kind: 'puc', + }); + expect(container.querySelector('span')).toBeNull(); + artifacts.dispose(); + 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('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('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://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('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(5_000); + 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 }); From 6d9241e15a0aab92b4efaf50735a5a63ec8fb5b9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:46:48 -0700 Subject: [PATCH 052/194] Cover cache reservation authority --- .../lib/test/services/reservations.test.ts | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-js/lib/test/services/reservations.test.ts b/crates/trusted-server-js/lib/test/services/reservations.test.ts index fa31fc3e9..b37dc0342 100644 --- a/crates/trusted-server-js/lib/test/services/reservations.test.ts +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -1502,7 +1502,7 @@ describe('Prebid admission leases and selection', () => { expect(service.recognize('native')).toEqual({ recognized: false }); }); - it('keeps a ten-second suppress-only lease, then atomically promotes the selected id to 15 minutes', () => { + it('promotes one selected cache lease from ten seconds to 15 minutes', () => { let now = 10; const { navigation } = runtimeNavigation(); const service = serviceAt(() => now); @@ -1512,7 +1512,7 @@ describe('Prebid admission leases and selection', () => { navigation, auctionId: 'fictional-auction', adUnitCode: 'fictional-slot', - renderSource: admSource(), + renderSource: cacheSource(), winnerContext: { selectedCpm: 1.25 }, prebidBid: bid, }; @@ -1549,6 +1549,20 @@ describe('Prebid admission leases and selection', () => { 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', () => { @@ -1894,11 +1908,11 @@ describe('atomic claims and disposal', () => { expect(attempt.winnerContext).toBeUndefined(); expect(service.snapshotInventoryForTest().entriesWithPucSource).toBe(0); }); - it('transfers immutable context before consumption and preserves it after projection replacement', () => { + it('preserves one cache source and immutable context after projection replacement', () => { const { navigation } = runtimeNavigation(); const attempt = renderAttempt(navigation); const service = serviceAt(() => 0); - const source = admSource('
original winner
'); + const source = cacheSource(); const context = { selectedCpm: 7.5 }; service.registerRender({ reservationId: reservationId(), @@ -1937,6 +1951,19 @@ describe('atomic claims and disposal', () => { 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', () => { From 799aa6e1e680cf8262c0e3944e1fd9b84b8bd6a2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:47:54 -0700 Subject: [PATCH 053/194] Refine the resilience implementation plan --- ...8-04-aps-tsjs-resilience-implementation.md | 659 ++++++++++++------ 1 file changed, 459 insertions(+), 200 deletions(-) diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index 95437e703..3b0562cbf 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -29,7 +29,7 @@ adapters. **Source of truth:** `docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md` revision 27, frozen review SHA -`6ed7fd4bafa31fe3a8112ad03ae5c600954d7568e6fef7ceabea5c9f8f94ab69`. This is the +`aab000fceaa4cfb303812fddf04b59aa172fd8034f4b5927acbf79d2ba180492`. This is the only implementation-plan document for the work. APS render and the runtime architecture are one coupled cutover: neither subsystem is useful or safe to release independently, so they remain in this one plan. @@ -59,9 +59,9 @@ safe to release independently, so they remain in this one plan. Every task ends with `git status --short`, focused verification, and one intentional commit before the next task. Stage only the exact paths from that task's **Files** list that the implementation changed; never use broad staging in a dirty worktree. -Use the task title as the commit subject, normalized to the repository's conventional -`test:`, `feat:`, `refactor:`, or `chore:` prefix. Task 19's coordinated production -switch is one atomic commit; do not split it into deployable half-states. +Use a descriptive sentence-case, imperative commit subject with no semantic prefix, +as required by `CLAUDE.md`. Task 19's coordinated production switch is one atomic +commit; do not split it into deployable half-states. ## Planned source shape @@ -605,26 +605,34 @@ collapse those checkpoints or carry unverified behavior between them. #### Task 5A: Define and test the common reserved-route and raw-proxy contract +**Task 5A files:** + +- `crates/trusted-server-core/src/integrations/aps.rs` +- `crates/trusted-server-core/src/integrations/mod.rs` +- `crates/trusted-server-core/src/integrations/registry.rs` +- `crates/trusted-server-core/src/platform/http.rs` +- `crates/trusted-server-core/src/platform/mod.rs` +- `crates/trusted-server-core/src/platform/test_support.rs` +- `crates/trusted-server-core/src/platform/types.rs` + - [ ] **Step A1: Write failing reserved-family and raw-proxy contract tests.** - Cover enabled `GET /integrations/aps/runner.js`; APS-disabled local - `404 no-store`; negative `/integrations/aps/runner/v1.js` and malformed family - paths; `405` plus `Allow: GET`; and proof that no reserved path reaches publisher - auth, EC, or fallback. At the common platform boundary, assert exact upstream - target/request evidence, the five-second dispatch-through-final-byte deadline, - cancellation, body cap, closed response grammar, and replacement headers. Static - renderer bytes and policy remain Task 5C. + In `trusted-server-core` only, cover reserved-family classification and the + `ApsV1Integration`/platform test-support contract with a fake transport. Assert the + exact upstream target/request evidence, five-second dispatch-through-final-byte + policy, cancellation, body cap, closed response grammar, replacement headers, and + empty non-leaking failures. Do not add or run real-adapter route tests in 5A; + adapter dispatch and method behavior belong to 5B, and static renderer bytes/policy + belong to 5C. - [ ] **Step A2: Run the new focused tests and prove they fail.** ```bash cargo test-fastly integrations::aps - cargo test-axum --test routes - cargo test-cloudflare --test routes - cargo test-spin --test routes ``` - Expected: the live runner route and raw-proxy policy are not implemented. + Expected: the bounded raw-proxy policy/evidence contract and fake response + validation are not implemented; no adapter suite has been changed. - [ ] **Step A3: Define the bounded raw-proxy platform contract.** @@ -662,15 +670,70 @@ collapse those checkpoints or carry unverified behavior between them. support; it does not claim actual-runtime parity or renderer behavior. ```bash - cargo test --package trusted-server-core --target aarch64-apple-darwin integrations::aps + cargo test-fastly integrations::aps cargo fmt --all -- --check - git add crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-core/src/integrations/mod.rs crates/trusted-server-core/src/integrations/registry.rs crates/trusted-server-core/src/platform + git add crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-core/src/integrations/mod.rs crates/trusted-server-core/src/integrations/registry.rs crates/trusted-server-core/src/platform/http.rs crates/trusted-server-core/src/platform/mod.rs crates/trusted-server-core/src/platform/test_support.rs crates/trusted-server-core/src/platform/types.rs git commit -m "Define the bounded APS runner proxy contract" ``` #### Task 5B: Implement and attest all four actual adapter transports -- [ ] **Step B1: Write and pass the complete actual-adapter proxy corpus.** +**Task 5B files:** + +- `crates/trusted-server-core/src/integrations/aps.rs` +- `crates/trusted-server-core/src/integrations/registry.rs` +- `crates/trusted-server-adapter-fastly/Cargo.toml` +- `crates/trusted-server-adapter-fastly/src/app.rs` +- `crates/trusted-server-adapter-fastly/src/main.rs` +- `crates/trusted-server-adapter-fastly/src/middleware.rs` +- `crates/trusted-server-adapter-fastly/src/platform.rs` +- `crates/trusted-server-adapter-axum/src/app.rs` +- `crates/trusted-server-adapter-axum/src/main.rs` +- `crates/trusted-server-adapter-axum/src/middleware.rs` +- `crates/trusted-server-adapter-axum/src/platform.rs` +- `crates/trusted-server-adapter-axum/tests/routes.rs` +- `crates/trusted-server-adapter-cloudflare/Cargo.toml` +- `crates/trusted-server-adapter-cloudflare/build.sh` +- `crates/trusted-server-adapter-cloudflare/src/app.rs` +- `crates/trusted-server-adapter-cloudflare/src/lib.rs` +- `crates/trusted-server-adapter-cloudflare/src/middleware.rs` +- `crates/trusted-server-adapter-cloudflare/src/platform.rs` +- `crates/trusted-server-adapter-cloudflare/tests/routes.rs` +- `crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml` +- `crates/trusted-server-adapter-spin/Cargo.toml` +- `crates/trusted-server-adapter-spin/src/app.rs` +- `crates/trusted-server-adapter-spin/src/lib.rs` +- `crates/trusted-server-adapter-spin/src/middleware.rs` +- `crates/trusted-server-adapter-spin/src/platform.rs` +- `crates/trusted-server-adapter-spin/tests/routes.rs` +- `crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml` +- `crates/trusted-server-integration-tests/fixtures/configs/cloudflare-aps-runner-proxy-fixture.toml` +- `crates/trusted-server-integration-tests/fixtures/cloudflare/aps-runner-proxy-service.js` +- `crates/trusted-server-integration-tests/Cargo.toml` +- `crates/trusted-server-integration-tests/README.md` +- `crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs` +- `crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs` +- `crates/trusted-server-integration-tests/tests/common/mod.rs` +- `crates/trusted-server-integration-tests/tests/common/runtime.rs` +- `crates/trusted-server-integration-tests/tests/environments/axum.rs` +- `crates/trusted-server-integration-tests/tests/environments/spin.rs` +- `crates/trusted-server-integration-tests/tests/environments/mod.rs` +- `crates/trusted-server-integration-tests/tests/environments/cloudflare.rs` +- `crates/trusted-server-integration-tests/tests/environments/fastly.rs` +- `crates/trusted-server-integration-tests/tests/parity.rs` +- `scripts/integration-tests-aps-runner-proxy.sh` +- `scripts/integration-tests-browser.sh` +- `scripts/integration-tests.sh` +- `.github/workflows/integration-tests.yml` +- `.tool-versions` +- `Cargo.lock` +- `CLAUDE.md` + +- [ ] **Step B1: Write the failing actual-adapter route and proxy corpus.** Cover enabled + `GET /integrations/aps/runner.js`; APS-disabled local `404 no-store`; negative + `/integrations/aps/runner/v1.js` and malformed family paths; `405` plus + `Allow: GET`; and proof that no reserved path reaches publisher auth, EC, or + fallback through Fastly, Axum, Cloudflare, or Spin. Drive each real transport boundary—including Cloudflare and Spin wasm and full Fastly routes—against a controlled fictional upstream. Cover status other than @@ -723,7 +786,23 @@ collapse those checkpoints or carry unverified behavior between them. and transport seam for the local Fastly simulator only; it is not an APS runner pin, and no APS runner version, digest, or body enters the repository. -- [ ] **Step B2: Implement each actual adapter transport, the reserved dispatcher, and the live** +- [ ] **Step B2: Run the new adapter route/corpus tests and prove they fail before implementation.** + + ```bash + cargo test-fastly + cargo test-axum --test routes + cargo test-cloudflare --test routes + cargo test-spin --test routes + ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum + ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly + ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare + ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin + ``` + + Expected: each runtime is missing its raw transport and/or reserved live-runner + dispatch; no Task 5C static-renderer assertion is part of this red gate. + +- [ ] **Step B3: Implement each actual adapter transport, the reserved dispatcher, and the live** **proxy response.** Register the family ahead of auth/EC/fallback through one explicit test-only @@ -739,7 +818,7 @@ collapse those checkpoints or carry unverified behavior between them. no-referrer policy. Every upstream or validation failure returns a local empty `502 no-store`, with no vendor body or descriptor/capability data in logs. -- [ ] **Step B3: Run and commit adapter transport parity before adding the static renderer.** +- [ ] **Step B4: Run and commit adapter transport parity before adding the static renderer.** ```bash cargo test-fastly @@ -751,12 +830,34 @@ collapse those checkpoints or carry unverified behavior between them. ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin - git add crates/trusted-server-adapter-fastly crates/trusted-server-adapter-axum crates/trusted-server-adapter-cloudflare crates/trusted-server-adapter-spin crates/trusted-server-integration-tests scripts/integration-tests-aps-runner-proxy.sh scripts/integration-tests.sh .github/workflows/integration-tests.yml + git add crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-core/src/integrations/registry.rs + git add crates/trusted-server-adapter-fastly/Cargo.toml crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-adapter-fastly/src/main.rs crates/trusted-server-adapter-fastly/src/middleware.rs crates/trusted-server-adapter-fastly/src/platform.rs + git add crates/trusted-server-adapter-axum/src/app.rs crates/trusted-server-adapter-axum/src/main.rs crates/trusted-server-adapter-axum/src/middleware.rs crates/trusted-server-adapter-axum/src/platform.rs crates/trusted-server-adapter-axum/tests/routes.rs + git add crates/trusted-server-adapter-cloudflare/Cargo.toml crates/trusted-server-adapter-cloudflare/build.sh crates/trusted-server-adapter-cloudflare/src/app.rs crates/trusted-server-adapter-cloudflare/src/lib.rs crates/trusted-server-adapter-cloudflare/src/middleware.rs crates/trusted-server-adapter-cloudflare/src/platform.rs crates/trusted-server-adapter-cloudflare/tests/routes.rs crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml + git add crates/trusted-server-adapter-spin/Cargo.toml crates/trusted-server-adapter-spin/src/app.rs crates/trusted-server-adapter-spin/src/lib.rs crates/trusted-server-adapter-spin/src/middleware.rs crates/trusted-server-adapter-spin/src/platform.rs crates/trusted-server-adapter-spin/tests/routes.rs + git add crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml crates/trusted-server-integration-tests/fixtures/configs/cloudflare-aps-runner-proxy-fixture.toml crates/trusted-server-integration-tests/fixtures/cloudflare/aps-runner-proxy-service.js crates/trusted-server-integration-tests/Cargo.toml crates/trusted-server-integration-tests/README.md + git add crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs crates/trusted-server-integration-tests/tests/common/mod.rs crates/trusted-server-integration-tests/tests/common/runtime.rs crates/trusted-server-integration-tests/tests/environments/axum.rs crates/trusted-server-integration-tests/tests/environments/spin.rs crates/trusted-server-integration-tests/tests/environments/mod.rs crates/trusted-server-integration-tests/tests/environments/cloudflare.rs crates/trusted-server-integration-tests/tests/environments/fastly.rs crates/trusted-server-integration-tests/tests/parity.rs + git add scripts/integration-tests-aps-runner-proxy.sh scripts/integration-tests-browser.sh scripts/integration-tests.sh .github/workflows/integration-tests.yml .tool-versions Cargo.lock CLAUDE.md git commit -m "Implement APS runner proxy parity" ``` #### Task 5C: Implement the static renderer and fictional browser fixture +**Task 5C files:** + +- `crates/trusted-server-core/src/integrations/aps.rs` +- `crates/trusted-server-core/src/integrations/registry.rs` +- `crates/trusted-server-adapter-fastly/src/app.rs` +- `crates/trusted-server-adapter-axum/tests/routes.rs` +- `crates/trusted-server-adapter-cloudflare/tests/routes.rs` +- `crates/trusted-server-adapter-spin/tests/routes.rs` +- `crates/trusted-server-integration-tests/tests/parity.rs` +- `crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts` +- `crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js` +- `docs/guide/error-reference.md` +- `docs/guide/getting-started.md` +- `docs/guide/testing.md` + - [ ] **Step C1: Write failing static-renderer route and policy tests.** Cover `/integrations/aps/renderer/v1`, disabled and unknown-version local `404 no-store`, malformed family paths, `405` plus `Allow: GET`, and proof the @@ -765,7 +866,21 @@ collapse those checkpoints or carry unverified behavior between them. referrer policy, and deliberate absence of `X-Frame-Options` and CSP `frame-ancestors`. -- [ ] **Step C2: Implement and test the static renderer contract.** +- [ ] **Step C2: Run the static-renderer tests and prove they fail before implementation.** + + ```bash + cargo test-fastly integrations::aps + npm --prefix crates/trusted-server-js/lib run check:aps-contract + node --test crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs + TS_TEST_APS_V1=1 TS_BROWSER_FRAMEWORKS=nextjs TS_BROWSER_PROJECTS=chromium \ + ./scripts/integration-tests-browser.sh \ + tests/shared/aps-renderer.spec.ts --project=chromium + ``` + + Expected: the versioned renderer route/body/policy or renderer-document behavior + is missing while the already-committed live proxy remains green. + +- [ ] **Step C3: Implement and test the static renderer contract.** The renderer validates/clears the fragment nonce, accepts one exact source-bound parent port, validates the descriptor and kernel-captured publisher origin, and @@ -779,7 +894,7 @@ collapse those checkpoints or carry unverified behavior between them. from document acceptance. Mutable APS callback correctness is an accepted external trust dependency, not a fact TS can derive from script load or body inspection. -- [ ] **Step C3: Add the hermetic fictional runner fixture.** +- [ ] **Step C4: Add the hermetic fictional runner fixture.** Author a minimal local fixture that implements only the documented event and queue/resolve/reject behavior. Assert it is neither a copy, transformation, nor @@ -787,7 +902,7 @@ collapse those checkpoints or carry unverified behavior between them. callback-silence, nested-iframe, and duplicate-callback tests. The fixture is not served as a production fallback and cannot be included in release bundles. -- [ ] **Step C4: Run the full route, transport, parity, and browser checks.** +- [ ] **Step C5: Run the full route, transport, parity, and browser checks.** ```bash cargo test-fastly @@ -804,11 +919,11 @@ collapse those checkpoints or carry unverified behavior between them. tests/shared/aps-renderer.spec.ts --project=chromium ``` -- [ ] **Step C5: Commit only the static renderer and fictional browser fixture after C1-C4** +- [ ] **Step C6: Commit only the static renderer and fictional browser fixture after C1-C5** are green. Adapter transport files must already be clean from Task 5B. ```bash - git add crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js scripts/integration-tests-browser.sh docs/guide/error-reference.md docs/guide/getting-started.md docs/guide/testing.md + git add crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-core/src/integrations/registry.rs crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-adapter-axum/tests/routes.rs crates/trusted-server-adapter-cloudflare/tests/routes.rs crates/trusted-server-adapter-spin/tests/routes.rs crates/trusted-server-integration-tests/tests/parity.rs crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js docs/guide/error-reference.md docs/guide/getting-started.md docs/guide/testing.md git commit -m "Implement the static APS renderer" ``` @@ -1010,7 +1125,7 @@ collapse those checkpoints or carry unverified behavior between them. crates/trusted-server-js/lib/eslint.config.js \ crates/trusted-server-js/lib/tsconfig.json \ crates/trusted-server-js/lib/vitest.config.ts - git commit -m "chore(tsjs): upgrade the package and TypeScript toolchain" + git commit -m "Upgrade the package and TypeScript toolchain" ``` Add only compatibility files that actually changed to the explicit staging list; @@ -1480,8 +1595,19 @@ collapse those checkpoints or carry unverified behavior between them. separate direct-cache context, plus URL/query/redirect/body/shape/macro cases, all three typed cache failures, and proof none becomes `no_bid`. -- [ ] **Step 5: Keep all remote side effects outside terminal correctness. APS has no synthetic** - notification. +- [ ] **Step 5: Finish render lifecycle behavior behind the test-only composition before any** + **production switch.** Snapshot render-relevant configuration when the attempt is + created, then re-check the navigation generation and the already-snapshotted + kill-switch state immediately before the earliest irreversible action: bridge + response, DOM insertion, or an existing non-APS notification. Prove a later + mutation cannot change an in-flight attempt and that an already-loaded page sees + configuration changes only through an existing response path; add no polling, + push channel, or event ingestion. + + Route existing non-APS `nurl`/`burl` behavior through the accepted terminal + transition so each notification initiates at most once and never blocks or changes + the render result. Add the explicit negative assertion that APS neither has nor + synthesizes either URL. Keep all remote side effects outside terminal correctness. - [ ] **Step 6: Run:** @@ -1747,8 +1873,11 @@ collapse those checkpoints or carry unverified behavior between them. - Modify: `crates/trusted-server-js/lib/test/services/slots.test.ts` - Modify: `crates/trusted-server-js/lib/src/services/targeting.ts` - Modify: `crates/trusted-server-js/lib/test/services/targeting.test.ts` +- Modify: `crates/trusted-server-js/lib/src/services/render.ts` +- Modify: `crates/trusted-server-js/lib/test/services/render.test.ts` - Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` - Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` +- Modify: `crates/trusted-server-core/src/publisher.rs` - [ ] **Step 1: Add or preserve failing tests for early unconditional GPT subscriptions,** publisher services already enabled, SRA, disabled initial load, one refresh path, @@ -1828,11 +1957,34 @@ collapse those checkpoints or carry unverified behavior between them. quarantine, and complete timer/candidate/reference disposal. Successful handoff cancels reconciliation and transfers cleanup ownership synchronously. -- [ ] **Step 7: Run the entire GPT suite, not only new files:** +- [ ] **Step 7: Add the attributable-empty-cycle fallback corpus and implementation before the** + **switch.** Prove fallback begins only after an attributable TS-owned empty GAM + cycle; the primary child settles before fallback starts; publisher, ambiguous, + quarantined, timeout, and stale cases do not fall back; both child histories + remain immutable; and `SlotOperation` publishes exactly one final result with + `path:'fallback'` when the fallback child runs. Exercise this through the GPT + adapter, slot service, render service, and test-only browser composition; do not + wire a shipped entry point yet. + +- [ ] **Step 8: Implement and test the prospective real performance marks before the switch.** + Add a unit-tested server boot-script fragment that executes + `performance.mark('tsjs:bids-script')` at the bids/projection boundary but leave + its production call site unchanged. In the GPT adapter, implement the + exactly-once `performance.mark('tsjs:first-display')` transition at the first + authoritative display call. Exercise both through test-only composition, + including replay, publisher/non-authoritative display, stale generation, and + missing/throwing Performance API cases, and assert + `performance.measure('tsjs:boot-to-first-display', 'tsjs:bids-script', 'tsjs:first-display')` + uses those exact marks. `window.__tsjsPerf` is baseline-only scaffolding and is + rejected by the prospective post-switch test. + +- [ ] **Step 9: Run the entire GPT suite and prospective boot-mark tests, not only new files:** ```bash npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt - npm --prefix crates/trusted-server-js/lib test -- --run test/adapters/googletag.test.ts test/services/slots.test.ts test/services/targeting.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/adapters/googletag.test.ts test/services/slots.test.ts test/services/targeting.test.ts test/services/render.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/composition/browser.test.ts + cargo test-fastly bids_script_performance_mark npm --prefix crates/trusted-server-js/lib run lint npm --prefix crates/trusted-server-js/lib run typecheck ``` @@ -2004,6 +2156,8 @@ implementation change. - Modify: `crates/trusted-server-js/lib/src/integrations/testlight/index.ts` - Modify: `crates/trusted-server-js/lib/src/core/trace.ts` - Modify: `crates/trusted-server-js/lib/test/core/trace.test.ts` +- Create: `crates/trusted-server-js/lib/src/kernel/diagnostics.ts` +- Create: `crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts` - Modify: `crates/trusted-server-js/lib/src/services/context.ts` - Modify: `crates/trusted-server-js/lib/test/services/context.test.ts` - Modify: `crates/trusted-server-js/lib/src/shared/async.ts` @@ -2040,6 +2194,7 @@ implementation change. - Modify: `crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts` - Modify: `crates/trusted-server-js/lib/test/integrations/sourcepoint/script_guard.test.ts` - Create: `crates/trusted-server-js/lib/test/integrations/testlight/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/build/release-v1.test.mjs` - Modify: `crates/trusted-server-js/lib/build-all.mjs` - Modify: `crates/trusted-server-core/src/publisher.rs` - Modify: `crates/trusted-server-core/src/trace_cookie.rs` @@ -2061,20 +2216,52 @@ implementation change. #### Task 18A: Rebuild creative as one independently green integration module +**Task 18A files:** + +- `crates/trusted-server-js/lib/src/integrations/creative/index.ts` +- `crates/trusted-server-js/lib/src/integrations/creative/click.ts` +- `crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts` +- `crates/trusted-server-js/lib/src/integrations/creative/iframe.ts` +- `crates/trusted-server-js/lib/src/integrations/creative/image.ts` +- `crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts` +- `crates/trusted-server-js/lib/src/shared/async.ts` +- `crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts` +- `crates/trusted-server-js/lib/src/shared/origin.ts` +- `crates/trusted-server-js/lib/src/shared/scheduler.ts` +- `crates/trusted-server-js/lib/src/shared/script_guard.ts` +- `crates/trusted-server-js/lib/test/integrations/creative/click.test.ts` +- `crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts` +- `crates/trusted-server-js/lib/test/integrations/creative/image.test.ts` +- `crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts` +- `crates/trusted-server-js/lib/test/integrations/creative/helpers.ts` +- `crates/trusted-server-js/lib/test/shared/async.test.ts` +- `crates/trusted-server-js/lib/test/shared/dom_insertion_dispatcher.test.ts` +- `crates/trusted-server-js/lib/test/shared/scheduler.test.ts` +- `crates/trusted-server-js/lib/src/composition/browser.ts` +- `crates/trusted-server-js/lib/test/composition/browser.test.ts` + - [ ] **Step A1: Add the failing creative-only composition and lifecycle corpus.** Cover boot validation, guard enablement combinations, automatic scans, wrapper and observer ownership, hostile callbacks, startup rollback, disposal, and every existing click/image/iframe/proxy-sign behavior. Run creative alone and inside a manifest composition without modifying any other integration. -- [ ] **Step A2: Convert only creative into a thin integration module.** Its +- [ ] **Step A2: Run the creative slice before implementation and prove the new composition** + **cases fail for the missing module lifecycle.** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/creative test/composition/browser.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/shared/async.test.ts test/shared/dom_insertion_dispatcher.test.ts test/shared/scheduler.test.ts + ``` + +- [ ] **Step A3: Convert only creative into a thin integration module.** Its `_registerIntegration({id,release,prepare})` call is pure registration; `prepare(ctx)` is inert and Promise-returning; `activate(ctx)` is synchronous, pre-registers disposal before every reversible mutation, and contributes at most one `afterCommit` callback. Keep shipped entry-point side effects unchanged until Task 19. -- [ ] **Step A3: Rebuild creative startup around the exact frozen `CreativeBootV1`.** Validate +- [ ] **Step A4: Rebuild creative startup around the exact frozen `CreativeBootV1`.** Validate the complete plain-object shape, defaults, disabled/manifest mismatch, unknown keys, accessors, prototypes, and literals before preparation. Activation installs the click guard when `clickGuard` is true and dynamic image/iframe guards when @@ -2092,25 +2279,84 @@ implementation change. rejection of credentials, malformed values, and non-network schemes. Delete the mutable/install creative globals only in Task 22. -- [ ] **Step A4: Run and commit the creative slice before diagnostics or other modules.** +- [ ] **Step A5: Run and commit the creative slice before diagnostics or other modules.** ```bash npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/creative test/composition/browser.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/shared/async.test.ts test/shared/dom_insertion_dispatcher.test.ts test/shared/scheduler.test.ts npm --prefix crates/trusted-server-js/lib run lint npm --prefix crates/trusted-server-js/lib run typecheck - git add crates/trusted-server-js/lib/src/integrations/creative crates/trusted-server-js/lib/test/integrations/creative crates/trusted-server-js/lib/src/composition/browser.ts crates/trusted-server-js/lib/test/composition/browser.test.ts + git add crates/trusted-server-js/lib/src/integrations/creative/index.ts crates/trusted-server-js/lib/src/integrations/creative/click.ts crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts crates/trusted-server-js/lib/src/integrations/creative/iframe.ts crates/trusted-server-js/lib/src/integrations/creative/image.ts crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts + git add crates/trusted-server-js/lib/test/integrations/creative/click.test.ts crates/trusted-server-js/lib/test/integrations/creative/helpers.ts crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts crates/trusted-server-js/lib/test/integrations/creative/image.test.ts crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts + git add crates/trusted-server-js/lib/src/shared/async.ts crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts crates/trusted-server-js/lib/src/shared/origin.ts crates/trusted-server-js/lib/src/shared/scheduler.ts crates/trusted-server-js/lib/src/shared/script_guard.ts crates/trusted-server-js/lib/test/shared/async.test.ts crates/trusted-server-js/lib/test/shared/dom_insertion_dispatcher.test.ts crates/trusted-server-js/lib/test/shared/scheduler.test.ts crates/trusted-server-js/lib/src/composition/browser.ts crates/trusted-server-js/lib/test/composition/browser.test.ts git commit -m "Prepare the creative integration module" ``` #### Task 18B: Rebuild diagnostics transport, producers, and consumers -- [ ] **Step B1: Move render tracing to the kernel diagnostics bus and exact public surface.** - `tsjs.diagnostics.renderTrace` exposes only frozen `current()`, `history()`, and - `subscribe()`. Keep current state keyed by exact slot and capped by the 256-slot - navigation registry; prune on disposal. Keep document-runtime history at 200, - one row per physical impression, monotonic `count`/global `seq`, immutable `at`, - and non-weakening enrichment. Remove stale DOM stamp fields/badges on update and - preserve bounded overlay/export failure isolation. +**Task 18B files:** + +- `crates/trusted-server-core/src/publisher.rs` +- `crates/trusted-server-core/src/trace_cookie.rs` +- `crates/trusted-server-core/src/integrations/gpt_diagnostics.rs` +- `crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js` +- `crates/trusted-server-js/lib/src/core/trace.ts` +- `crates/trusted-server-js/lib/test/core/trace.test.ts` +- `crates/trusted-server-js/lib/src/kernel/diagnostics.ts` +- `crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts` +- `crates/trusted-server-js/lib/src/services/render.ts` +- `crates/trusted-server-js/lib/test/services/render.test.ts` +- `crates/trusted-server-js/lib/src/adapters/googletag.ts` +- `crates/trusted-server-js/lib/test/adapters/googletag.test.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts` +- `crates/trusted-server-js/lib/src/composition/browser.ts` +- `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step B1: Add the failing diagnostics slice before implementation.** Cover the + closure-private kernel bus, 15/16/17 integration-module subscriptions, + manifest-member identity, immutable post-correctness observations, subscriber + throw isolation, and proof that no publisher-facing API can obtain its register + or publish authority. Add failing render-trace, GPT-fact, producer-ordering, + inactive-zero-effect, composition, and `ts_console` request-pipeline cases. + +- [ ] **Step B2: Run the diagnostics slice and prove it fails at the missing internal bus,** + **producer wiring, and server session mechanics.** + + ```bash + cargo test-fastly trace_cookie + cargo test-fastly ts_console + npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/diagnostics.test.ts test/core/trace.test.ts test/services/render.test.ts test/adapters/googletag.test.ts test/integrations/gpt_diagnostics test/composition/browser.test.ts + ``` + +- [ ] **Step B3: Move render tracing to the kernel diagnostics bus and exact public surface.** + Implement the bus in `kernel/diagnostics.ts` as a closure-private runtime owner, + not a property reachable from `window.tsjs`, `TsjsApi`, diagnostics snapshots, or + publisher callbacks. Admit only identities from the validated manifest and at + most 16 live integration-module subscriptions; reject the seventeenth without + disturbing the first sixteen. Publisher code can use only the separately + bounded public read-only diagnostic subscriptions described below. + + `tsjs.diagnostics.renderTrace` exposes only frozen `current()`, `history()`, and + `subscribe()`. Keep current state keyed by exact slot and capped by the 256-slot + navigation registry; prune on disposal. Keep document-runtime history at 200, one + row per physical impression, monotonic `count`/global `seq`, immutable `at`, and + non-weakening enrichment. Remove stale DOM stamp fields/badges on update and preserve + bounded overlay/export failure isolation. Commit correctness state before public delivery. Capture subscriber ids and enqueue frozen full records asynchronously in a 200-entry FIFO keyed by `seq`; same-sequence @@ -2120,7 +2366,7 @@ implementation change. registration-during-dispatch, callback throw isolation, and 199/200/201 overflow. Emit no `CustomEvent`, mutable trace global, or compatibility alias. -- [ ] **Step B2: Preserve GPT diagnostics through the adapter event stream.** Validate exact +- [ ] **Step B4: Preserve GPT diagnostics through the adapter event stream.** Validate exact `DiagnosticsBootV1` plus manifest activation before any listener/buffer exists. When active, core owns the six documented GPT observations before TS requests, buffers 512 raw facts until module activation, then replays and releases the @@ -2138,7 +2384,7 @@ implementation change. storage, upload, old flag, runtime expando, or `tsjs.gptDiagnostics` alias remains after Task 22. -- [ ] **Step B3: Rebuild and unit-test the server-owned `ts_console` browser-session mechanics.** +- [ ] **Step B5: Rebuild and unit-test the server-owned `ts_console` browser-session mechanics.** On eligible GET document navigations, accept exactly one case-sensitive `ts_console=1|true` enable directive or `0|false` disable directive; duplicate, conflicting, empty, or unknown values fail closed for that response. @@ -2147,8 +2393,13 @@ implementation change. host-only `Secure`, `HttpOnly`, `SameSite=Lax` session cookie. Assert same-origin tab/session behavior, disabled-by-default behavior, and that frozen `DiagnosticsBootV1.gpt.active` is the only browser-visible activation result. + Write request-pipeline tests named with `ts_console` before implementation and + prove they fail for directive stripping, method/document eligibility, + unrelated URL preservation, exact `Set-Cookie`, clearing, and boot-emitter + activation. These assertions must exercise `publisher.rs`, not only the + isolated trace-cookie parser. -- [ ] **Step B4: Wire every diagnostics producer explicitly after its correctness commit.** +- [ ] **Step B6: Wire every diagnostics producer explicitly after its correctness commit.** `RenderAttempt` publishes immutable render observations only after terminal or accepted-artifact state commits; the sole GPT adapter publishes its six raw facts only after adapter bookkeeping commits. Both use the kernel-owned bus, @@ -2157,21 +2408,75 @@ implementation change. event ordering, enrichment replacement, buffer release, navigation disposal, and absence of `CustomEvent`, mutable globals, or a second GPT listener set. -- [ ] **Step B5: Run and commit diagnostics transport, producer, and consumer wiring as one** +- [ ] **Step B7: Run and commit diagnostics transport, producer, and consumer wiring as one** independently green slice. ```bash - cargo test --package trusted-server-core --target aarch64-apple-darwin trace_cookie - npm --prefix crates/trusted-server-js/lib test -- --run test/core/trace.test.ts test/services/render.test.ts test/adapters/googletag.test.ts test/integrations/gpt_diagnostics + cargo test-fastly trace_cookie + cargo test-fastly ts_console + npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/diagnostics.test.ts test/core/trace.test.ts test/services/render.test.ts test/adapters/googletag.test.ts test/integrations/gpt_diagnostics + npm --prefix crates/trusted-server-js/lib test -- --run test/composition/browser.test.ts npm --prefix crates/trusted-server-js/lib run lint npm --prefix crates/trusted-server-js/lib run typecheck - git add crates/trusted-server-core/src/trace_cookie.rs crates/trusted-server-core/src/integrations/gpt_diagnostics.rs crates/trusted-server-js/lib/src/core/trace.ts crates/trusted-server-js/lib/src/services crates/trusted-server-js/lib/src/adapters/googletag.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics crates/trusted-server-js/lib/test/core/trace.test.ts crates/trusted-server-js/lib/test/services/render.test.ts crates/trusted-server-js/lib/test/adapters/googletag.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics + git add crates/trusted-server-core/src/publisher.rs crates/trusted-server-core/src/trace_cookie.rs crates/trusted-server-core/src/integrations/gpt_diagnostics.rs crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js + git add crates/trusted-server-js/lib/src/kernel/diagnostics.ts crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts crates/trusted-server-js/lib/src/core/trace.ts crates/trusted-server-js/lib/test/core/trace.test.ts crates/trusted-server-js/lib/src/services/render.ts crates/trusted-server-js/lib/test/services/render.test.ts crates/trusted-server-js/lib/src/adapters/googletag.ts crates/trusted-server-js/lib/test/adapters/googletag.test.ts + git add crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts + git add crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts + git add crates/trusted-server-js/lib/src/composition/browser.ts crates/trusted-server-js/lib/test/composition/browser.test.ts git commit -m "Rebuild bounded runtime diagnostics" ``` #### Task 18C: Migrate the remaining integrations and maximal manifest -- [ ] **Step C1: Preserve each remaining `rc/july` integration corpus exactly.** Cover DataDome +**Task 18C files:** + +- `crates/trusted-server-js/lib/src/integrations/datadome/index.ts` +- `crates/trusted-server-js/lib/src/integrations/datadome/script_guard.ts` +- `crates/trusted-server-js/lib/src/integrations/didomi/index.ts` +- `crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts` +- `crates/trusted-server-js/lib/src/integrations/google_tag_manager/script_guard.ts` +- `crates/trusted-server-js/lib/src/integrations/lockr/index.ts` +- `crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts` +- `crates/trusted-server-js/lib/src/integrations/osano/index.ts` +- `crates/trusted-server-js/lib/src/integrations/permutive/index.ts` +- `crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts` +- `crates/trusted-server-js/lib/src/integrations/permutive/segments.ts` +- `crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts` +- `crates/trusted-server-js/lib/src/integrations/sourcepoint/script_guard.ts` +- `crates/trusted-server-js/lib/src/integrations/testlight/index.ts` +- `crates/trusted-server-js/lib/test/integrations/datadome/script_guard.test.ts` +- `crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts` +- `crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts` +- `crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts` +- `crates/trusted-server-js/lib/test/integrations/osano/index.test.ts` +- `crates/trusted-server-js/lib/test/integrations/permutive/segments.test.ts` +- `crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts` +- `crates/trusted-server-js/lib/test/integrations/sourcepoint/script_guard.test.ts` +- `crates/trusted-server-js/lib/test/integrations/testlight/index.test.ts` +- `crates/trusted-server-js/lib/test/build/release-v1.test.mjs` +- `crates/trusted-server-js/lib/src/services/context.ts` +- `crates/trusted-server-js/lib/test/services/context.test.ts` +- `crates/trusted-server-js/lib/src/shared/beacon_guard.ts` +- `crates/trusted-server-js/lib/src/shared/globals.ts` +- `crates/trusted-server-js/lib/src/shared/script_guard.ts` +- `crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts` +- `crates/trusted-server-js/lib/src/composition/browser.ts` +- `crates/trusted-server-js/lib/test/composition/browser.test.ts` +- `crates/trusted-server-js/lib/build-all.mjs` +- `crates/trusted-server-core/src/integrations/datadome.rs` +- `crates/trusted-server-core/src/integrations/datadome/protection.rs` +- `crates/trusted-server-core/src/integrations/datadome/protection_scope.rs` +- `crates/trusted-server-core/src/integrations/didomi.rs` +- `crates/trusted-server-core/src/integrations/google_tag_manager.rs` +- `crates/trusted-server-core/src/integrations/lockr.rs` +- `crates/trusted-server-core/src/integrations/osano.rs` +- `crates/trusted-server-core/src/integrations/permutive.rs` +- `crates/trusted-server-core/src/integrations/sourcepoint.rs` +- `crates/trusted-server-core/src/integrations/testlight.rs` +- `crates/trusted-server-core/src/integrations/mod.rs` + +- [ ] **Step C1: Add the failing remaining-integration and maximal-manifest corpus.** Preserve + each `rc/july` integration behavior exactly. Cover DataDome script/preload path rewriting; Didomi absolute SDK path without config clobber; GTM script/preload and GA beacon/fetch rewriting; Lockr bounded readiness and API host; Osano USP/GPP/TCF marker ownership and lifecycle; Permutive bounded @@ -2184,21 +2489,30 @@ implementation change. provider survives failed activation or module/runtime disposal, and SPA navigation does not register a duplicate. -- [ ] **Step C2: Convert only the remaining integrations into thin modules.** Each + Load core followed by every server-declared integration in manifest order and + assert one runtime, no unknown id, no duplicate activation, exact reverse-order + disposal, and no leaked timer, listener, wrapper, observer, context provider, or + queued continuation. Run each module alone and in the maximal manifest with missing + globals, timeout, malformed config/consent/storage, matcher false positives, + callback throws, startup failure, and cross-integration isolation. + +- [ ] **Step C2: Run the complete new corpus before conversion and prove the module lifecycle** + **and maximal-manifest cases fail.** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/datadome test/integrations/didomi test/integrations/google_tag_manager test/integrations/lockr test/integrations/osano test/integrations/permutive test/integrations/sourcepoint test/integrations/testlight + npm --prefix crates/trusted-server-js/lib test -- --run test/services/context.test.ts test/shared/beacon_guard.test.ts test/composition/browser.test.ts + npm --prefix crates/trusted-server-js/lib run test:release + cargo test-fastly publisher + ``` + +- [ ] **Step C3: Convert only the remaining integrations into thin modules.** Each `_registerIntegration({id,release,prepare})` call is pure registration; `prepare(ctx)` is inert and Promise-returning; `activate(ctx)` is synchronous, pre-registers disposal before reversible mutation, and contributes at most one `afterCommit`. Shared helpers must preserve each integration's exact matcher, startup order, failure isolation, and disposal semantics. -- [ ] **Step C3: Add the maximal-bundle failing smoke test.** Load core followed by every - server-declared integration in manifest order and assert one runtime, no unknown - id, no duplicate activation, exact reverse-order disposal, and no leaked timer, - listener, wrapper, observer, context provider, or queued continuation. Run each - module alone and in the maximal manifest with missing globals, timeout, malformed - config/consent/storage, matcher false positives, callback throws, startup - failure, and cross-integration isolation. - - [ ] **Step C4: Generate and test the prospective manifest member list/order from the exact** enabled bundle list. Embed the same release id in core and every integration IIFE. Add failures for integration before core, unknown/missing/duplicate member, @@ -2222,39 +2536,22 @@ implementation change. diagnostics changes from Tasks 18A/18B into this commit. ```bash - git add crates/trusted-server-js/lib/src/integrations crates/trusted-server-js/lib/test/integrations crates/trusted-server-js/lib/src/shared crates/trusted-server-js/lib/test/shared crates/trusted-server-js/lib/src/composition/browser.ts crates/trusted-server-js/lib/test/composition/browser.test.ts crates/trusted-server-js/lib/build-all.mjs crates/trusted-server-core/src/integrations + git add crates/trusted-server-js/lib/src/integrations/datadome/index.ts crates/trusted-server-js/lib/src/integrations/datadome/script_guard.ts crates/trusted-server-js/lib/src/integrations/didomi/index.ts crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts crates/trusted-server-js/lib/src/integrations/google_tag_manager/script_guard.ts + git add crates/trusted-server-js/lib/src/integrations/lockr/index.ts crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts crates/trusted-server-js/lib/src/integrations/osano/index.ts crates/trusted-server-js/lib/src/integrations/permutive/index.ts crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts crates/trusted-server-js/lib/src/integrations/permutive/segments.ts + git add crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts crates/trusted-server-js/lib/src/integrations/sourcepoint/script_guard.ts crates/trusted-server-js/lib/src/integrations/testlight/index.ts + git add crates/trusted-server-js/lib/test/integrations/datadome/script_guard.test.ts crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts crates/trusted-server-js/lib/test/integrations/osano/index.test.ts crates/trusted-server-js/lib/test/integrations/permutive/segments.test.ts + git add crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts crates/trusted-server-js/lib/test/integrations/sourcepoint/script_guard.test.ts crates/trusted-server-js/lib/test/integrations/testlight/index.test.ts + git add crates/trusted-server-js/lib/src/services/context.ts crates/trusted-server-js/lib/test/services/context.test.ts crates/trusted-server-js/lib/src/shared/beacon_guard.ts crates/trusted-server-js/lib/src/shared/globals.ts crates/trusted-server-js/lib/src/shared/script_guard.ts crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts crates/trusted-server-js/lib/src/composition/browser.ts crates/trusted-server-js/lib/test/composition/browser.test.ts crates/trusted-server-js/lib/test/build/release-v1.test.mjs crates/trusted-server-js/lib/build-all.mjs + git add crates/trusted-server-core/src/integrations/datadome.rs crates/trusted-server-core/src/integrations/datadome/protection.rs crates/trusted-server-core/src/integrations/datadome/protection_scope.rs crates/trusted-server-core/src/integrations/didomi.rs crates/trusted-server-core/src/integrations/google_tag_manager.rs crates/trusted-server-core/src/integrations/lockr.rs crates/trusted-server-core/src/integrations/osano.rs crates/trusted-server-core/src/integrations/permutive.rs crates/trusted-server-core/src/integrations/sourcepoint.rs crates/trusted-server-core/src/integrations/testlight.rs crates/trusted-server-core/src/integrations/mod.rs git commit -m "Prepare the remaining integration modules" ``` -### Task 19: Complete lifecycle behavior and perform the coordinated production switch +### Task 19: Perform the coordinated production wiring switch **Files:** -- Modify: `crates/trusted-server-js/lib/src/services/render.ts` -- Modify: `crates/trusted-server-js/lib/src/services/slots.ts` -- Modify: `crates/trusted-server-js/lib/src/services/projections.ts` -- Modify: `crates/trusted-server-js/lib/src/services/targeting.ts` -- Modify: `crates/trusted-server-js/lib/src/services/reservations.ts` -- Modify: `crates/trusted-server-js/lib/src/services/auction_batch.ts` -- Modify: `crates/trusted-server-js/lib/src/services/context.ts` -- Modify: `crates/trusted-server-js/lib/src/kernel/integration_registry.ts` -- Modify: `crates/trusted-server-js/lib/src/kernel/runtime.ts` -- Modify: `crates/trusted-server-js/lib/src/kernel/sessions.ts` -- Modify: `crates/trusted-server-js/lib/src/adapters/googletag.ts` -- Modify: `crates/trusted-server-js/lib/src/adapters/prebid.ts` -- Modify: `crates/trusted-server-js/lib/src/adapters/messaging.ts` -- Modify: `crates/trusted-server-js/lib/src/core/config.ts` -- Modify: `crates/trusted-server-js/lib/src/core/global.d.ts` -- Modify: `crates/trusted-server-js/lib/src/core/log.ts` -- Modify: `crates/trusted-server-js/lib/src/core/queue.ts` -- Modify: `crates/trusted-server-js/lib/src/core/registry.ts` -- Modify: `crates/trusted-server-js/lib/src/core/trace.ts` -- Modify: `crates/trusted-server-js/lib/src/core/types.ts` -- Modify: `crates/trusted-server-js/lib/src/core/request.ts` -- Modify: `crates/trusted-server-js/lib/src/core/auction.ts` - Modify: `crates/trusted-server-js/lib/src/core/index.ts` - Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` -- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` - Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` - Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` - Modify: `crates/trusted-server-js/lib/src/integrations/creative/index.ts` @@ -2277,7 +2574,6 @@ implementation change. - Modify: `crates/trusted-server-adapter-axum/src/app.rs` - Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs` - Modify: `crates/trusted-server-adapter-spin/src/app.rs` -- Modify: `crates/trusted-server-integration-tests/tests/parity.rs` - Modify: `crates/trusted-server-core/src/html_processor.rs` - Modify: `crates/trusted-server-core/src/integrations/prebid.rs` - Modify: `crates/trusted-server-core/src/integrations/didomi.rs` @@ -2285,50 +2581,9 @@ implementation change. - Modify: `crates/trusted-server-core/src/integrations/gpt.rs` - Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics.rs` - Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js` -- Modify: `crates/trusted-server-js/lib/build-prebid-external.mjs` - Modify: `crates/trusted-server-js/lib/build-all.mjs` -- Modify: `crates/trusted-server-js/lib/test/core/index.test.ts` -- Modify: `crates/trusted-server-js/lib/test/core/request.test.ts` -- Modify: `crates/trusted-server-js/lib/test/core/auction.test.ts` -- Modify: `crates/trusted-server-js/lib/test/kernel/runtime.test.ts` -- Modify: `crates/trusted-server-js/lib/test/services/render.test.ts` -- Modify: `crates/trusted-server-js/lib/test/services/slots.test.ts` -- Modify: `crates/trusted-server-js/lib/test/services/projections.test.ts` -- Modify: `crates/trusted-server-js/lib/test/services/targeting.test.ts` -- Modify: `crates/trusted-server-js/lib/test/services/reservations.test.ts` -- Modify: `crates/trusted-server-js/lib/test/services/auction_batch.test.ts` -- Modify: `crates/trusted-server-js/lib/test/services/context.test.ts` -- Modify: `crates/trusted-server-js/lib/test/core/queue.test.ts` -- Modify: `crates/trusted-server-js/lib/test/core/registry.test.ts` -- Modify: `crates/trusted-server-js/lib/test/core/log.test.ts` -- Modify: `crates/trusted-server-js/lib/test/core/trace.test.ts` -- Modify: `crates/trusted-server-js/lib/test/adapters/googletag.test.ts` -- Modify: `crates/trusted-server-js/lib/test/adapters/prebid.test.ts` -- Modify: `crates/trusted-server-js/lib/test/adapters/messaging.test.ts` -- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts` -- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` -- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` -- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` -- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts` - -- [ ] **Step 1: Add failing tests proving fallback begins only after an attributable TS-owned** - empty GAM cycle; the primary child settles before fallback starts; publisher, - ambiguous, quarantined, timeout, and stale cases do not fall back; both child - histories remain immutable; and `SlotOperation` publishes exactly one final - result with `path:'fallback'` when the child runs. - -- [ ] **Step 2: Snapshot render-relevant configuration at attempt creation. Re-check generation** - and existing kill-switch state immediately before the earliest irreversible - action (bridge response, DOM insertion, or an existing non-APS notification). -- [ ] **Step 3: Preserve existing non-APS `nurl`/`burl` behavior but route it through the attempt** - terminal transition so it initiates once and never blocks. Add an assertion that - APS never synthesizes either URL. - -- [ ] **Step 4: Test already-loaded-page limits honestly: configuration changes reach a page only** - through an existing response path; do not add polling, push, or event ingestion. - -- [ ] **Step 5: Complete the pre-switch checklist with no production-wiring changes staged.** +- [ ] **Step 1: Complete the pre-switch checklist with no production-wiring changes staged.** The atomic switch is allowed to flip wiring only after every behavior suite below is already green against the test-only composition and prospective routes/artifacts: @@ -2349,20 +2604,22 @@ implementation change. rebased into the immutable pre-change artifact; the gate must be green after the atomic switch and Task 22 legacy deletion, before release readiness. - Install the real performance marks before this checklist closes. Execute - `performance.mark('tsjs:bids-script')` in the actual server-emitted bids/projection - boot script, and execute `performance.mark('tsjs:first-display')` exactly once at - the first authoritative GPT display call in the real adapter path. The browser - performance fixture must measure those marks with - `performance.measure('tsjs:boot-to-first-display', 'tsjs:bids-script', 'tsjs:first-display')`; - `window.__tsjsPerf` remains baseline-capture scaffolding and cannot satisfy the - post-switch gate. + Verify the prospective performance-mark tests from Task 16 are already green: the + unit-tested server fragment names `tsjs:bids-script`, the adapter names exactly one + authoritative `tsjs:first-display`, and the measure uses those exact marks. Task 19 + may connect only their already-tested production call sites; it may not add or + repair mark behavior. `window.__tsjsPerf` remains baseline-capture scaffolding and + cannot satisfy the post-switch gate. ```bash + git diff --cached --quiet + npm --prefix crates/trusted-server-js/lib run test:release + npm --prefix crates/trusted-server-js/lib run test:architecture + npm --prefix crates/trusted-server-js/lib test -- --run test/core/index.test.ts test/kernel/runtime.test.ts test/composition/browser.test.ts npm --prefix crates/trusted-server-js/lib test -- --run \ test/services test/kernel test/adapters test/core npm --prefix crates/trusted-server-js/lib test -- --run \ - test/integrations test/composition test/build + test/integrations test/composition npm --prefix crates/trusted-server-js/lib run build npm --prefix crates/trusted-server-js/lib run lint npm --prefix crates/trusted-server-js/lib run typecheck @@ -2371,70 +2628,30 @@ implementation change. cargo test-cloudflare cargo test-spin cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity + ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum + ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly + ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare + ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin ``` -- [ ] **Step 6: Atomically activate the new production surface in one task and one commit:** - - `/auction` emits/parses only the exact decision-set/tagged-source wire, and - initial HTML/page-bids emit only `tsjs.boot.auctionProjection`; - - the immutable initial projection seeds the first `NavigationSession`; every SPA - page-bids response validates and commits only to the replacement session's - internal projection and never mutates recursively frozen `tsjs.boot`; - - projection parsing enforces the exact 256-array/member, identifier, targeting, - currency/CPM, reservation, dimension, and canonical 8 MiB bounds before mutation; - an over-cap projection converts every otherwise winning decision to - `winner_not_renderable`, emits no projected bid, and omits the corresponding - `/auction` TS seatbid; - - the server emits exact frozen `TsjsBootV1`, `CreativeBootV1`, - `DiagnosticsBootV1`, and `BootManifestV1` before core from generated release - metadata, after validating every integration config and manifest relationship; - - core inertly prepares every required integration in manifest order while no - bridge/listener/global mutation is live. Only after all Promises resolve does the - same-task synchronous activation barrier install the capture bridge as its first - reversible core effect, install correctness GPT listeners, and activate modules - in order with monotonic pre/post-call and pre-handoff checks. Failure rolls back - every reversible effect; success commits the complete `TsjsApi`, runs staged - `afterCommit` callbacks in manifest order, and drains the preload queue; - - the preload queue handoff uses the exact real-Array algorithm: capture ingress, - install the fixed installing descriptor, snapshot, forward retained ingress - pushes, install the frozen final actual Array with own immediate `push` and - `length:0`, publish the complete API, run `afterCommit`, then drain snapshot plus - forwarded work exactly once. Native/borrowed mutators and retained references - cannot retain entries or create a second runtime; - - the kernel surface is exactly `TsjsApi` with semantic `version`, exact - `releaseId`, immutable `boot`, real `que`, `addAdUnits`, Promise `requestAds`, - local `log`, diagnostics, `_registerIntegration`, and frozen status-only - `_internal`. Fallback exposes its exact smaller own surface, validates then refuses - `addAdUnits`, settles known slots with the committed fallback reason, drains the - queue once, and creates no runtime/adapters/listeners/timers/DOM work; - - `addAdUnits` transactionally validates and registers programmatic direct-auction - slots against the same combined 256-slot cap, exact identifier/bidder/dimension - grammar, and collision indexes. Omitted-slot `requestAds` snapshots server and - programmatic registrations in ordinal order; later registrations cannot enter an - in-flight snapshot; - - GPT, Prebid, APS, creative, diagnostics, all remaining integrations, Promise - `requestAds`, versioned APS renderer client, and generated bootstrap/fallback - switch together on the shared sessions/services and terminal latches; - - the external publisher artifact switches as independently useful pure Prebid.js - 10.26.0 with its own watchdog and frozen artifact stamp; TS admission, render, - refresh, targeting, and release matching remain only in the separate Prebid - integration module; - - all adapters atomically register only the versioned static renderer and - unversioned live `/integrations/aps/runner.js` proxy; the abandoned - `/integrations/aps/runner/v1.js` and unversioned renderer are local negative - routes; - - every Rust/JS integration config emitter moves its existing values from - scattered `window.__tsjs_*` globals into its exact `tsjs.boot.*` member before - the corresponding integration prepares; no integration loses configuration; - - accepted artifacts, `WinnerContext`, targeting journals, renderer reservations, - GPT physical-object reconciliation, and navigation ownership use the shared - services; and - - render trace and GPT diagnostics commit only after correctness transitions and - expose their exact bounded asynchronous frozen APIs. Creative guards auto-install - from frozen boot configuration and both-false guards have zero DOM side effects; - and - - the real boot/render path records the named `tsjs:bids-script` and - `tsjs:first-display` performance marks at their authoritative transitions; the - temporary `__tsjsPerf` baseline shim is not carried into the switched runtime. +- [ ] **Step 2: Atomically switch production wiring in one task and one commit.** Make no + validator, state-machine, lifecycle, adapter, or test behavior changes here: + - point `/auction`, initial HTML, and page-bids production emitters at the + already-tested exact decision/projection serializers and boot-script fragments, + including the preimplemented `tsjs:bids-script` mark; + - make the sole browser composition root construct the already-tested runtime, + services, adapters, integration modules, fallback, and queue handoff, then have + each thin integration `index.ts` delegate to that composition without retaining a + second registry or behavior branch; + - switch generated release/manifest/config/bootstrap emission and the independently + built pure Prebid 10.26.0 artifact to those already-tested entry points; and + - register the already-tested versioned APS renderer and live unversioned runner + proxy through all four adapter dispatchers while preserving the negative routes. + + The switch is a hard cutover: add no selector, dual manifest, compatibility alias, + protocol autodetection, or fallback to old behavior. The old implementation may + remain physically present only while unreachable; Task 22 deletes it before + release. Before enabling the Fastly production route, run the unchanged stall/slow-drip deadline cases through a non-production Fastly Compute service and a controlled @@ -2448,17 +2665,56 @@ implementation change. manifest, or shape autodetection. The temporarily unused server routes and old declarations are deleted in Task 22 before release. -- [ ] **Step 7: Run:** +- [ ] **Step 3: Stage only the wiring allowlist, prove the diff contains no behavior/test files,** + **run the full gate, and commit.** Any required behavior or test repair fails this + checkpoint and returns to the owning earlier task; do not widen the allowlist. ```bash + git add \ + crates/trusted-server-js/lib/src/core/index.ts \ + crates/trusted-server-js/lib/src/composition/browser.ts \ + crates/trusted-server-js/lib/src/integrations/{gpt,prebid,creative,datadome,didomi,google_tag_manager,gpt_diagnostics,lockr,osano,permutive,sourcepoint,testlight}/index.ts \ + crates/trusted-server-js/lib/build-all.mjs + git add \ + crates/trusted-server-core/src/integrations/gpt_bootstrap.js \ + crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-core/src/tsjs.rs \ + crates/trusted-server-core/src/auction/{endpoints,formats}.rs \ + crates/trusted-server-core/src/integrations/{registry,prebid,didomi,sourcepoint,gpt,gpt_diagnostics}.rs \ + crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js \ + crates/trusted-server-core/src/html_processor.rs + git add \ + crates/trusted-server-adapter-fastly/src/app.rs \ + crates/trusted-server-adapter-axum/src/app.rs \ + crates/trusted-server-adapter-cloudflare/src/app.rs \ + crates/trusted-server-adapter-spin/src/app.rs + git diff --name-only --cached | awk ' + /^(crates\/trusted-server-js\/lib\/(src\/core\/index\.ts|src\/composition\/browser\.ts|src\/integrations\/(gpt|prebid|creative|datadome|didomi|google_tag_manager|gpt_diagnostics|lockr|osano|permutive|sourcepoint|testlight)\/index\.ts|build-all\.mjs)|crates\/trusted-server-core\/src\/(integrations\/gpt_bootstrap\.js|publisher\.rs|tsjs\.rs|auction\/(endpoints|formats)\.rs|integrations\/(registry|prebid|didomi|sourcepoint|gpt|gpt_diagnostics)\.rs|integrations\/gpt_diagnostics_bootstrap\.js|html_processor\.rs)|crates\/trusted-server-adapter-(fastly|axum|cloudflare|spin)\/src\/app\.rs)$/ { next } + { print "unexpected non-wiring path: " $0; bad = 1 } + END { exit bad ? 1 : 0 } + ' + git diff --exit-code --cached -- \ + crates/trusted-server-js/lib/src/services \ + crates/trusted-server-js/lib/src/kernel \ + crates/trusted-server-js/lib/src/adapters \ + crates/trusted-server-js/lib/test npm --prefix crates/trusted-server-js/lib test -- --run test/services test/core test/integrations/gpt npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid test/integrations/aps test/kernel + npm --prefix crates/trusted-server-js/lib run test:release + npm --prefix crates/trusted-server-js/lib run test:architecture npm --prefix crates/trusted-server-js/lib run build + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run typecheck cargo test-fastly cargo test-axum cargo test-cloudflare cargo test-spin cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity + ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum + ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly + ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare + ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin + git commit -m "Switch production to the resilient TSJS runtime" ``` ### Phase 4 exit @@ -2466,7 +2722,7 @@ implementation change. - GPT, Prebid, APS, and all integration entry points use one kernel/integration-module surface. - The old registries, sentinels, expandos, refresh wrappers, and bridge branches are - gone. + unreachable behind production wiring; physical deletion remains Task 22. - All Vitest and production-bundle tests pass. ## Phase 5 — browser conformance, deletion, and release readiness @@ -2582,6 +2838,9 @@ implementation change. 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 ``` From 06314ee40303c7601c9b88452f08c5599564a4b3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:54:08 -0700 Subject: [PATCH 054/194] Complete the resilience plan review gates --- .../2026-08-04-aps-tsjs-resilience-implementation.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index 3b0562cbf..8f41b7d94 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -74,6 +74,7 @@ crates/trusted-server-js/lib/src/ identity.ts navigation-prefix + u64 attempts; 128-bit CSPRNG tickets/nonces disposable.ts owned disposer stack and terminal latch primitives integration_registry.ts release-matched prepare/activate transaction + diagnostics.ts bounded failure-isolated internal diagnostics bus runtime.ts bootstrap ownership and shared Runtime object sessions.ts RuntimeSession and NavigationSession adapters/ @@ -793,6 +794,7 @@ collapse those checkpoints or carry unverified behavior between them. cargo test-axum --test routes cargo test-cloudflare --test routes cargo test-spin --test routes + cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare @@ -870,6 +872,10 @@ collapse those checkpoints or carry unverified behavior between them. ```bash cargo test-fastly integrations::aps + cargo test-axum --test routes + cargo test-cloudflare --test routes + cargo test-spin --test routes + cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity npm --prefix crates/trusted-server-js/lib run check:aps-contract node --test crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs TS_TEST_APS_V1=1 TS_BROWSER_FRAMEWORKS=nextjs TS_BROWSER_PROJECTS=chromium \ @@ -2502,6 +2508,7 @@ implementation change. ```bash npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/datadome test/integrations/didomi test/integrations/google_tag_manager test/integrations/lockr test/integrations/osano test/integrations/permutive test/integrations/sourcepoint test/integrations/testlight npm --prefix crates/trusted-server-js/lib test -- --run test/services/context.test.ts test/shared/beacon_guard.test.ts test/composition/browser.test.ts + npm --prefix crates/trusted-server-js/lib run build npm --prefix crates/trusted-server-js/lib run test:release cargo test-fastly publisher ``` @@ -2613,6 +2620,7 @@ implementation change. ```bash git diff --cached --quiet + npm --prefix crates/trusted-server-js/lib run build npm --prefix crates/trusted-server-js/lib run test:release npm --prefix crates/trusted-server-js/lib run test:architecture npm --prefix crates/trusted-server-js/lib test -- --run test/core/index.test.ts test/kernel/runtime.test.ts test/composition/browser.test.ts From b781a1bf8c681e08f27581a5e495f22216bd1d76 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:59:08 -0700 Subject: [PATCH 055/194] Add the Universal Creative bridge dispatcher --- .../lib/src/adapters/messaging.ts | 39 ++++ .../lib/src/services/puc_bridge.ts | 216 ++++++++++++++++++ .../lib/test/adapters/messaging.test.ts | 73 ++++++ .../lib/test/services/puc_bridge.test.ts | 215 +++++++++++++++++ 4 files changed, 543 insertions(+) create mode 100644 crates/trusted-server-js/lib/src/services/puc_bridge.ts create mode 100644 crates/trusted-server-js/lib/test/services/puc_bridge.test.ts diff --git a/crates/trusted-server-js/lib/src/adapters/messaging.ts b/crates/trusted-server-js/lib/src/adapters/messaging.ts index 492e411af..ff50f86cd 100644 --- a/crates/trusted-server-js/lib/src/adapters/messaging.ts +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -219,6 +219,13 @@ export interface MessagingAdapter { transferred: readonly MessagingPort[] ): boolean; installCaptureListener(listener: CaptureMessageListener): () => void; + inspectGlobalMessage(candidate: unknown): + | Readonly<{ + message: string; + adId?: string; + lifecycleTicket?: string; + }> + | undefined; parseProtocolMessage( kind: ProtocolMessageKind, candidate: unknown @@ -491,6 +498,36 @@ function parseGlobalJson(candidate: unknown): unknown { } } +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[] @@ -1307,6 +1344,7 @@ export function createBrowserMessagingAdapter( rollback(); }; }, + inspectGlobalMessage, parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => parseProtocolMessage(kind, candidate, validation), extractTransferredPorts, @@ -1319,6 +1357,7 @@ export function createNoopMessagingAdapter(): MessagingAdapter { createChannel: () => undefined, postWindow: () => false, installCaptureListener: () => () => undefined, + inspectGlobalMessage, parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => parseProtocolMessage(kind, candidate, {}), extractTransferredPorts, diff --git a/crates/trusted-server-js/lib/src/services/puc_bridge.ts b/crates/trusted-server-js/lib/src/services/puc_bridge.ts new file mode 100644 index 000000000..be4772c05 --- /dev/null +++ b/crates/trusted-server-js/lib/src/services/puc_bridge.ts @@ -0,0 +1,216 @@ +import { + TSJS_MESSAGE_PROTOCOL_V1, + type MessagingAdapter, + type MessagingPort, +} from '../adapters/messaging'; + +import type { ReservationRecognition, ReservationService } from './reservations'; + +const mapGetIntrinsic = Map.prototype.get; +const mapSetIntrinsic = Map.prototype.set; +const mapClearIntrinsic = Map.prototype.clear; +const mapSizeGetter = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.get as ( + this: Map +) => number; +const mapValuesIntrinsic = Map.prototype.values; +const mapIteratorNextIntrinsic = Object.getPrototypeOf(new Map().values()).next as ( + this: IterableIterator +) => IteratorResult; +const jsonStringifyIntrinsic = JSON.stringify; +const objectFreezeIntrinsic = Object.freeze; + +interface PendingClaim { + readonly port: MessagingPort; + readonly source: object; +} + +export interface PucBridgeOptions { + readonly messaging: MessagingAdapter; + readonly reservations: Pick; +} + +export interface PucBridgeInventory { + readonly disposed: boolean; + readonly pendingClaims: number; +} + +export interface PucBridge { + dispose(): void; + snapshotInventoryForTest(): PucBridgeInventory; +} + +function mapValue(map: Map, key: Key): Value | undefined { + return Reflect.apply(mapGetIntrinsic, map, [key]) as Value | undefined; +} + +function setMapValue(map: Map, key: Key, value: Value): void { + Reflect.apply(mapSetIntrinsic, map, [key, value]); +} + +function mapSize(map: Map): number { + return Reflect.apply(mapSizeGetter, map, []) as number; +} + +function snapshotMapValues(map: Map): readonly Value[] { + const iterator = Reflect.apply(mapValuesIntrinsic, map, []) as IterableIterator; + const values: Value[] = []; + while (true) { + const step = Reflect.apply(mapIteratorNextIntrinsic, iterator, []) as IteratorResult; + if (step.done) return values; + values[values.length] = step.value; + } +} + +function frozen(value: Value): Readonly { + return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; +} + +function recognizedReservation( + reservations: Pick, + reservationId: string +): ReservationRecognition | undefined { + try { + return reservations.recognize(reservationId); + } catch { + return undefined; + } +} + +function suppress(event: unknown): boolean { + try { + if (typeof event !== 'object' || event === null) return false; + const stop = Reflect.get(event, 'stopImmediatePropagation'); + if (typeof stop !== 'function') return false; + Reflect.apply(stop, event, []); + return true; + } catch { + return false; + } +} + +function eventData(event: unknown): unknown { + try { + return typeof event === 'object' && event !== null ? Reflect.get(event, 'data') : undefined; + } catch { + return undefined; + } +} + +function eventSource(event: unknown): object | undefined { + try { + if (typeof event !== 'object' || event === null) return undefined; + const source = Reflect.get(event, 'source'); + return (typeof source === 'object' || typeof source === 'function') && source !== null + ? source + : undefined; + } catch { + return undefined; + } +} + +function refusedResponse(adId: string): string | undefined { + try { + const owner = Object.create(null) as Record; + owner['version'] = 1; + owner['status'] = TSJS_MESSAGE_PROTOCOL_V1.status.refused; + const response = Object.create(null) as Record; + response['message'] = TSJS_MESSAGE_PROTOCOL_V1.message.prebidResponse; + response['adId'] = adId; + response['rendererVersion'] = TSJS_MESSAGE_PROTOCOL_V1.rendererVersion; + response['tsOwner'] = owner; + const serialized = Reflect.apply(jsonStringifyIntrinsic, JSON, [response]) as unknown; + return typeof serialized === 'string' ? serialized : undefined; + } catch { + return undefined; + } +} + +function refuse(port: MessagingPort, adId: string): void { + try { + const response = refusedResponse(adId); + if (response !== undefined) port.post(response, []); + } catch { + // Refusal transport is best-effort; endpoint closure remains mandatory. + } finally { + try { + port.close(); + } catch { + // The adapter contains raw close failures, but keep this boundary fail-closed. + } + } +} + +/** + * Own the runtime-wide Universal Creative capture dispatcher. + * + * Request recognition deliberately precedes exact parsing and port inspection so + * malformed or replayed TS capabilities cannot fall through to native Prebid. + */ +export function createPucBridge(options: PucBridgeOptions): PucBridge { + const messaging = options.messaging; + const reservations = options.reservations; + const pendingClaims = new Map(); + let disposed = false; + + const dispatch = (event: MessageEvent): void => { + if (disposed) return; + const data = eventData(event); + const routing = messaging.inspectGlobalMessage(data); + if ( + routing?.message !== TSJS_MESSAGE_PROTOCOL_V1.message.prebidRequest || + routing.adId === undefined + ) { + return; + } + + const recognition = recognizedReservation(reservations, routing.adId); + if (recognition?.recognized !== true) return; + if (!suppress(event)) return; + + const exact = messaging.parseProtocolMessage('prebidRequest', data); + const ports = messaging.extractTransferredPorts(event, 1); + const port = ports?.[0]; + if (!port) return; + if (exact === undefined || recognition.state !== 'renderable') { + refuse(port, routing.adId); + return; + } + + const source = eventSource(event); + if (source === undefined || mapValue(pendingClaims, routing.adId) !== undefined) { + refuse(port, routing.adId); + return; + } + + setMapValue(pendingClaims, routing.adId, frozen({ port, source })); + }; + + const uninstall = messaging.installCaptureListener(dispatch); + + const bridge: PucBridge = { + dispose(): void { + if (disposed) return; + disposed = true; + try { + uninstall(); + } catch { + // Listener removal is already contained by the adapter. + } + const claims = snapshotMapValues(pendingClaims); + for (let index = 0; index < claims.length; index += 1) { + const claim = claims[index]; + if (!claim) continue; + try { + claim.port.close(); + } catch { + // Endpoint cleanup is exact-once at the adapter facade. + } + } + Reflect.apply(mapClearIntrinsic, pendingClaims, []); + }, + snapshotInventoryForTest(): PucBridgeInventory { + return frozen({ disposed, pendingClaims: mapSize(pendingClaims) }); + }, + }; + return frozen(bridge); +} diff --git a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts index bc39cea2e..3d4c02474 100644 --- a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts @@ -764,6 +764,79 @@ describe('browser messaging adapter', () => { } }); + it('inspects only own routing data before exact global-message parsing', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const json = JSON.stringify({ + message: 'Prebid Request', + adId: 'r1_abcdefghijklmnopqrstuv', + adServerDomain: 'ads.example.com', + ignored: { renderer: '' }, + }); + const object = Object.assign(Object.create(null), { + message: 'TS Render Owner Register', + adId: 'r1_abcdefghijklmnopqrstuv', + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + ignored: true, + }); + + const inspectedJson = adapter.inspectGlobalMessage(json); + const inspectedObject = adapter.inspectGlobalMessage(object); + + expect(inspectedJson).toEqual({ + message: 'Prebid Request', + adId: 'r1_abcdefghijklmnopqrstuv', + }); + expect(inspectedObject).toEqual({ + message: 'TS Render Owner Register', + adId: 'r1_abcdefghijklmnopqrstuv', + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }); + expect(Object.isFrozen(inspectedJson)).toBe(true); + expect(Object.isFrozen(inspectedObject)).toBe(true); + }); + + it('inspects global routing data without invoking accessors or inherited properties', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const getter = vi.fn(() => 'Prebid Request'); + const accessor = Object.create(null) as Record; + Object.defineProperty(accessor, 'message', { get: getter, enumerable: true }); + Object.defineProperty(accessor, 'adId', { + value: 'r1_abcdefghijklmnopqrstuv', + enumerable: true, + }); + const inherited = Object.assign(Object.create({ message: 'Prebid Request' }), { + adId: 'r1_abcdefghijklmnopqrstuv', + }); + const throwingProxy = new Proxy( + {}, + { + getPrototypeOf: () => { + throw new Error('prototype trap'); + }, + } + ); + + expect(adapter.inspectGlobalMessage(accessor)).toBeUndefined(); + expect(adapter.inspectGlobalMessage(inherited)).toBeUndefined(); + expect(adapter.inspectGlobalMessage(throwingProxy)).toBeUndefined(); + expect(getter).not.toHaveBeenCalled(); + }); + + it('rejects malformed, duplicate-key, and oversized routing JSON during inspection', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const duplicate = '{"message":"Prebid Request","adId":"first","adId":"second","ignored":true}'; + const oversized = JSON.stringify({ + message: 'Prebid Request', + adId: 'r1_abcdefghijklmnopqrstuv', + ignored: 'é'.repeat(2_100), + }); + + expect(adapter.inspectGlobalMessage('{')).toBeUndefined(); + expect(adapter.inspectGlobalMessage(duplicate)).toBeUndefined(); + expect(adapter.inspectGlobalMessage(oversized)).toBeUndefined(); + expect(adapter.inspectGlobalMessage({ adId: 'r1_abcdefghijklmnopqrstuv' })).toBeUndefined(); + }); + it('does not invoke accessors while rejecting an exact-shape candidate', () => { const adapter = createBrowserMessagingAdapter(createTarget()); const getter = vi.fn(() => 'TS Owner Inserted'); 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..ea171ba85 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createBrowserMessagingAdapter } from '../../src/adapters/messaging'; +import { createPucBridge } from '../../src/services/puc_bridge'; +import type { ReservationRecognition } from '../../src/services/reservations'; + +const RESERVATION_ID = 'r1_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 createHarness(recognize: (reservationId: unknown) => ReservationRecognition) { + let listener: ((event: MessageEvent) => void) | undefined; + const target = { + addEventListener: vi.fn( + (_type: 'message', next: (event: MessageEvent) => void, _capture: true) => { + listener = next; + } + ), + removeEventListener: vi.fn(), + }; + const bridge = createPucBridge({ + messaging: createBrowserMessagingAdapter(target), + reservations: { recognize }, + }); + 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 }; +} + +describe('Universal Creative bridge dispatcher', () => { + 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({ + disposed: false, + pendingClaims: 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({ + disposed: true, + pendingClaims: 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 and closes every available port', () => { + const harness = createHarness(() => ({ + recognized: true, + state: 'renderable', + expiresAt: 1_000, + })); + const first = createPort(); + const second = createPort(); + const stopImmediatePropagation = vi.fn(); + + harness.dispatch({ + data: exactRequest(), + ports: [first, second], + source: Object.freeze({}), + stopImmediatePropagation, + }); + + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(first.postMessage).not.toHaveBeenCalled(); + expect(second.postMessage).not.toHaveBeenCalled(); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); + }); + + it('buffers only the first exact live claim and generically refuses a duplicate', () => { + const harness = createHarness(() => ({ + recognized: true, + state: 'renderable', + expiresAt: 1_000, + })); + const first = createPort(); + const duplicate = createPort(); + const source = Object.freeze({ frame: 'authoritative' }); + + 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(['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); + } + ); +}); From f35248f2cae7c3d397db72a5c67e716092995744 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:19:41 -0700 Subject: [PATCH 056/194] Harden cache rendering boundaries --- .../lib/src/composition/browser.ts | 17 +- .../lib/src/services/render.ts | 623 ++++++++++++----- .../lib/test/composition/browser.test.ts | 61 ++ .../lib/test/core/config.test.ts | 35 + .../lib/test/services/render.test.ts | 636 +++++++++++++++++- .../lib/test/services/reservations.test.ts | 2 +- 6 files changed, 1165 insertions(+), 209 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index e488e8093..eef7e1967 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -234,7 +234,22 @@ export function createTestBrowserRuntimeComposition( } }; const renderDirectCache = (attempt: RenderAttempt, container: HTMLElement): boolean => { - if (!cachePolicy || typeof fetchCache !== 'function') return false; + 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, diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index d83e81d98..f24ad403d 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -24,10 +24,19 @@ const directAdmOwnerDocumentGetter = ? undefined : Object.getOwnPropertyDescriptor(Node.prototype, 'ownerDocument')?.get; const objectFreezeIntrinsic = Object.freeze; +const objectIsFrozenIntrinsic = Object.isFrozen; +const objectGetPrototypeOfIntrinsic = Object.getPrototypeOf; +const objectGetOwnPropertyNamesIntrinsic = Object.getOwnPropertyNames; +const objectGetOwnPropertySymbolsIntrinsic = Object.getOwnPropertySymbols; +const objectGetOwnPropertyDescriptorIntrinsic = Object.getOwnPropertyDescriptor; +const objectCreateIntrinsic = Object.create; const objectToStringIntrinsic = Object.prototype.toString; +const objectHasOwnIntrinsic = Object.prototype.hasOwnProperty; +const arrayIsArrayIntrinsic = Array.isArray; const arrayIncludesIntrinsic = Array.prototype.includes; const arrayPushIntrinsic = Array.prototype.push; const arraySliceIntrinsic = Array.prototype.slice; +const arraySortIntrinsic = Array.prototype.sort; const arraySpliceIntrinsic = Array.prototype.splice; const mapGetIntrinsic = Map.prototype.get; const mapSetIntrinsic = Map.prototype.set; @@ -63,13 +72,71 @@ const weakSetAddIntrinsic = WeakSet.prototype.add; const weakSetHasIntrinsic = WeakSet.prototype.has; const weakSetDeleteIntrinsic = WeakSet.prototype.delete; const promiseThenIntrinsic = Promise.prototype.then; +const numberIsFiniteIntrinsic = Number.isFinite; +const numberIsIntegerIntrinsic = Number.isInteger; +const regexpTestIntrinsic = RegExp.prototype.test; +const stringCharCodeAtIntrinsic = String.prototype.charCodeAt; const stringIndexOfIntrinsic = String.prototype.indexOf; const stringSliceIntrinsic = String.prototype.slice; +const stringTrimIntrinsic = String.prototype.trim; const stringIntrinsic = String; const jsonParseIntrinsic = JSON.parse; +const urlIntrinsic = URL; +type UrlTextProperty = + | 'href' + | 'protocol' + | 'hostname' + | 'username' + | 'password' + | 'origin' + | 'port' + | 'pathname' + | 'search' + | 'hash'; + +function captureUrlGetter(name: UrlTextProperty): ((this: URL) => string) | undefined { + let prototype: object | null = URL.prototype; + while (prototype) { + const getter = Object.getOwnPropertyDescriptor(prototype, name)?.get; + if (typeof getter === 'function') return getter as (this: URL) => string; + prototype = Object.getPrototypeOf(prototype) as object | null; + } + return undefined; +} + +const urlGetters: Readonly string) | undefined>> = + Object.freeze({ + href: captureUrlGetter('href'), + protocol: captureUrlGetter('protocol'), + hostname: captureUrlGetter('hostname'), + username: captureUrlGetter('username'), + password: captureUrlGetter('password'), + origin: captureUrlGetter('origin'), + port: captureUrlGetter('port'), + pathname: captureUrlGetter('pathname'), + search: captureUrlGetter('search'), + hash: captureUrlGetter('hash'), + }); +const encodeURIComponentIntrinsic = encodeURIComponent; +const textEncoder = new TextEncoder(); +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; +const fatalTextDecoder = new TextDecoder('utf-8', { fatal: true }); +const textDecoderDecodeIntrinsic = TextDecoder.prototype.decode; +const abortControllerIntrinsic = AbortController; +const abortControllerAbortIntrinsic = AbortController.prototype.abort; +const abortControllerSignalGetter = Object.getOwnPropertyDescriptor( + AbortController.prototype, + 'signal' +)?.get as (this: AbortController) => AbortSignal; +const abortSignalAbortedGetter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted') + ?.get as (this: AbortSignal) => boolean; const artifactDisposals = new WeakMap(); const committedArtifactStores = new WeakSet(); const renderAttempts = new WeakSet(); +const cacheAttemptControls = new WeakMap< + object, + Readonly<{ begin: () => boolean; complete: () => boolean }> +>(); const ignoreAsyncDisposal = (): void => undefined; function frozen(value: Value): Readonly { @@ -80,6 +147,62 @@ function arrayPush(array: Value[], value: Value): number { return Reflect.apply(arrayPushIntrinsic, array, [value]) as number; } +function utf8Length(value: string): number { + return (reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [value]) as Uint8Array) + .byteLength; +} + +function isFiniteNumber(value: number): boolean { + return reflectApplyIntrinsic(numberIsFiniteIntrinsic, Number, [value]) as boolean; +} + +function isInteger(value: number): boolean { + return reflectApplyIntrinsic(numberIsIntegerIntrinsic, Number, [value]) as boolean; +} + +function regexpTest(pattern: RegExp, value: string): boolean { + return reflectApplyIntrinsic(regexpTestIntrinsic, pattern, [value]) as boolean; +} + +function hasOwn(value: object, name: PropertyKey): boolean { + return reflectApplyIntrinsic(objectHasOwnIntrinsic, value, [name]) as boolean; +} + +function objectIsFrozen(value: object): boolean { + return reflectApplyIntrinsic(objectIsFrozenIntrinsic, Object, [value]) as boolean; +} + +function objectPrototype(value: object): object | null { + return reflectApplyIntrinsic(objectGetPrototypeOfIntrinsic, Object, [value]) as object | null; +} + +function ownPropertyNames(value: object): string[] { + return reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [value]) as string[]; +} + +function ownPropertySymbols(value: object): symbol[] { + return reflectApplyIntrinsic(objectGetOwnPropertySymbolsIntrinsic, Object, [value]) as symbol[]; +} + +function ownPropertyDescriptor(value: object, name: PropertyKey): PropertyDescriptor | undefined { + return reflectApplyIntrinsic(objectGetOwnPropertyDescriptorIntrinsic, Object, [value, name]) as + PropertyDescriptor | undefined; +} + +function isArray(value: unknown): value is unknown[] { + return reflectApplyIntrinsic(arrayIsArrayIntrinsic, Array, [value]) as boolean; +} + +function sortedStrings(values: string[]): string[] { + return reflectApplyIntrinsic(arraySortIntrinsic, values, []) as string[]; +} + +function urlPart(url: URL, name: UrlTextProperty): string { + const getter = urlGetters[name]; + if (typeof getter !== 'function') throw new TypeError('missing URL accessor'); + return reflectApplyIntrinsic(getter, url, []) as string; +} + function isUint8Array(value: unknown): value is Uint8Array { return ( (typeof value === 'object' || typeof value === 'function') && @@ -377,8 +500,6 @@ export interface RenderAttempt { readonly beginGamClaim: () => boolean; readonly ownerClaimed: () => boolean; readonly ownerRegistered: () => boolean; - readonly beginCacheFetch: () => boolean; - readonly cacheFetchCompleted: () => boolean; readonly beginDirect: () => boolean; readonly beginApsDocument: (artifact: CommittedRenderArtifact) => boolean; readonly beginAdm: (artifact: CommittedRenderArtifact) => boolean; @@ -407,12 +528,35 @@ interface CacheFetchReader { interface CacheFetchResponse { readonly body: Readonly<{ getReader: () => CacheFetchReader }> | null; readonly ok: boolean; - readonly type?: Response['type']; + readonly type: Response['type']; +} + +type CacheFetcher = (input: string, init: RequestInit) => Promise; + +export interface CacheAdmSource { + readonly adm: string; + readonly height: number; + readonly type: 'adm'; + readonly version: 1; + readonly width: number; +} + +export interface CacheFetchPolicy { + readonly baseUrl: string; + readonly version: 1; +} + +export interface CacheAdmResolutionOptions { + readonly attempt: RenderAttempt; + readonly cachePolicy: Readonly; + readonly fetcher: CacheFetcher; + readonly onResolved: (source: Readonly) => boolean; + readonly publisherOrigin: string; } export interface DirectCacheAttemptOptions extends DirectAdmAttemptOptions { - readonly cachePolicy: Readonly<{ version: 1; baseUrl: string }>; - readonly fetcher: (input: string, init: RequestInit) => Promise; + readonly cachePolicy: Readonly; + readonly fetcher: CacheFetcher; } export interface DirectAdmIframeHandle { @@ -528,9 +672,9 @@ function validOutcome(value: unknown): value is RenderOutcome { if ( typeof value !== 'object' || value === null || - !Object.isFrozen(value) || - Object.getPrototypeOf(value) !== Object.prototype || - Object.getOwnPropertySymbols(value).length !== 0 + !objectIsFrozen(value) || + objectPrototype(value) !== Object.prototype || + ownPropertySymbols(value).length !== 0 ) { return false; } @@ -570,7 +714,7 @@ function validArtifact( try { if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return false; if (!Object.isFrozen(value) || Object.getPrototypeOf(value) !== Object.prototype) return false; - const names = Object.getOwnPropertyNames(value).sort(); + const names = sortedStrings(ownPropertyNames(value)); if ( names.length !== 5 || names[0] !== 'attemptId' || @@ -1080,20 +1224,20 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp if ( (typeof context !== 'object' && typeof context !== 'function') || context === null || - !Object.isFrozen(context) || - Object.getPrototypeOf(context) !== Object.prototype || - Object.getOwnPropertyNames(context).length !== 1 || - Object.getOwnPropertySymbols(context).length !== 0 + !objectIsFrozen(context) || + objectPrototype(context) !== Object.prototype || + ownPropertyNames(context).length !== 1 || + ownPropertySymbols(context).length !== 0 ) { return false; } - const selectedCpm = Object.getOwnPropertyDescriptor(context, 'selectedCpm'); + const selectedCpm = ownPropertyDescriptor(context, 'selectedCpm'); return ( !!selectedCpm && 'value' in selectedCpm && selectedCpm.enumerable === true && typeof selectedCpm.value === 'number' && - Number.isFinite(selectedCpm.value) && + isFiniteNumber(selectedCpm.value) && selectedCpm.value >= 0 ); } catch { @@ -1360,21 +1504,6 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp ? enter(['waiting_for_gam_and_claim'], 'waiting_for_owner') : false, ownerRegistered: () => enter(['waiting_for_owner'], 'waiting_for_insertion'), - beginCacheFetch, - cacheFetchCompleted: () => { - if ( - admittedRenderSource?.type !== 'cache' || - !admittedWinnerContext || - outcome !== undefined || - (state !== 'rendering_direct' && state !== 'waiting_for_insertion') || - deadlineState !== state || - !ownerIsCurrent() - ) { - return false; - } - clearDeadline(); - return true; - }, beginDirect: () => (admittedRenderSource?.type === 'aps' || admittedRenderSource?.type === 'adm') && admittedWinnerContext @@ -1507,17 +1636,37 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp disposeRejectedOwner(); return frozen({ ok: false, reason: 'stale_owner' }); } - weakSetAdd(renderAttempts, lifecycle); - return frozen({ ok: true, value: frozen(lifecycle) }); + const exposedLifecycle = frozen(lifecycle); + weakSetAdd(renderAttempts, exposedLifecycle); + weakMapSet( + cacheAttemptControls, + exposedLifecycle, + frozen({ + begin: beginCacheFetch, + complete: () => { + const cacheState = state; + if ( + admittedRenderSource?.type !== 'cache' || + !admittedWinnerContext || + outcome !== undefined || + (cacheState !== 'rendering_direct' && cacheState !== 'waiting_for_insertion') || + deadlineState !== cacheState || + !ownerIsCurrent() + ) { + return false; + } + clearDeadline(); + if (cacheState === 'waiting_for_insertion' && outcome === undefined) { + armDeadline(cacheState); + } + return outcome === undefined && state === cacheState && ownerIsCurrent(); + }, + }) + ); + return frozen({ ok: true, value: exposedLifecycle }); } -type DirectAdmSource = Readonly<{ - adm: string; - height: number; - type: 'adm'; - version: 1; - width: number; -}>; +type DirectAdmSource = Readonly; type DirectCacheSource = Readonly<{ cacheId: string; @@ -1540,19 +1689,22 @@ function exactFrozenDataRecord( if ( typeof value !== 'object' || value === null || - !Object.isFrozen(value) || - Object.getPrototypeOf(value) !== Object.prototype || - Object.getOwnPropertySymbols(value).length !== 0 + !objectIsFrozen(value) || + objectPrototype(value) !== Object.prototype || + ownPropertySymbols(value).length !== 0 ) { return undefined; } - const names = Object.getOwnPropertyNames(value).sort(); + const names = sortedStrings(ownPropertyNames(value)); if (names.length !== expectedNames.length) return undefined; - const fields = Object.create(null) as Record; + const fields = reflectApplyIntrinsic(objectCreateIntrinsic, Object, [null]) as Record< + string, + unknown + >; for (let index = 0; index < expectedNames.length; index += 1) { const name = expectedNames[index]; if (!name || names[index] !== name) return undefined; - const descriptor = Object.getOwnPropertyDescriptor(value, name); + const descriptor = ownPropertyDescriptor(value, name); if ( !descriptor || !('value' in descriptor) || @@ -1579,30 +1731,32 @@ function readCachePolicyBase(value: unknown): URL | undefined { fields['version'] !== 1 || typeof baseUrl !== 'string' || baseUrl.length === 0 || - new TextEncoder().encode(baseUrl).byteLength > MAX_CACHE_URL_BYTES + utf8Length(baseUrl) > MAX_CACHE_URL_BYTES ) { return undefined; } for (let index = 0; index < baseUrl.length; index += 1) { - const code = baseUrl.charCodeAt(index); + const code = reflectApplyIntrinsic(stringCharCodeAtIntrinsic, baseUrl, [index]) as number; if (code <= 0x1f || code === 0x7f) return undefined; if (code >= 0xd800 && code <= 0xdbff) { - const next = baseUrl.charCodeAt(index + 1); + const next = reflectApplyIntrinsic(stringCharCodeAtIntrinsic, baseUrl, [ + index + 1, + ]) as number; if (next < 0xdc00 || next > 0xdfff) return undefined; index += 1; } else if (code >= 0xdc00 && code <= 0xdfff) { return undefined; } } - const base = new URL(baseUrl); + const base = Reflect.construct(urlIntrinsic, [baseUrl]) as URL; if ( - base.protocol !== 'https:' || - base.hostname === '' || - base.username !== '' || - base.password !== '' || - base.search !== '' || - base.hash !== '' || - base.pathname === '/' + urlPart(base, 'protocol') !== 'https:' || + urlPart(base, 'hostname') === '' || + urlPart(base, 'username') !== '' || + urlPart(base, 'password') !== '' || + urlPart(base, 'search') !== '' || + urlPart(base, 'hash') !== '' || + urlPart(base, 'pathname') === '/' ) { return undefined; } @@ -1632,16 +1786,16 @@ function readDirectCacheSource( fields['type'] !== 'cache' || fields['version'] !== 1 || typeof fields['cacheId'] !== 'string' || - !CACHE_ID.test(fields['cacheId']) || + !regexpTest(CACHE_ID, fields['cacheId']) || typeof fields['fetchUrl'] !== 'string' || fields['fetchUrl'].length === 0 || - new TextEncoder().encode(fields['fetchUrl']).byteLength > MAX_CACHE_URL_BYTES || + utf8Length(fields['fetchUrl']) > MAX_CACHE_URL_BYTES || typeof fields['width'] !== 'number' || - !Number.isInteger(fields['width']) || + !isInteger(fields['width']) || fields['width'] < 1 || fields['width'] > 4096 || typeof fields['height'] !== 'number' || - !Number.isInteger(fields['height']) || + !isInteger(fields['height']) || fields['height'] < 1 || fields['height'] > 4096 ) { @@ -1649,32 +1803,36 @@ function readDirectCacheSource( } const sourceFetchUrl = fields['fetchUrl'] as string; for (let index = 0; index < sourceFetchUrl.length; index += 1) { - const code = sourceFetchUrl.charCodeAt(index); + const code = reflectApplyIntrinsic(stringCharCodeAtIntrinsic, sourceFetchUrl, [ + index, + ]) as number; if (code <= 0x1f || code === 0x7f) return undefined; if (code >= 0xd800 && code <= 0xdbff) { - const next = sourceFetchUrl.charCodeAt(index + 1); + const next = reflectApplyIntrinsic(stringCharCodeAtIntrinsic, sourceFetchUrl, [ + index + 1, + ]) as number; if (next < 0xdc00 || next > 0xdfff) return undefined; index += 1; } else if (code >= 0xdc00 && code <= 0xdfff) { return undefined; } } - const fetchUrl = new URL(sourceFetchUrl); - const expected = new URL(base.href); - expected.search = `?uuid=${encodeURIComponent(fields['cacheId'])}`; + const fetchUrl = Reflect.construct(urlIntrinsic, [sourceFetchUrl]) as URL; + const canonicalSearch = `?uuid=${ + reflectApplyIntrinsic(encodeURIComponentIntrinsic, undefined, [fields['cacheId']]) as string + }`; + const expectedHref = `${urlPart(base, 'href')}${canonicalSearch}`; if ( - fetchUrl.protocol !== 'https:' || - fetchUrl.username !== '' || - fetchUrl.password !== '' || - fetchUrl.hash !== '' || - fetchUrl.origin !== base.origin || - fetchUrl.port !== base.port || - fetchUrl.pathname !== base.pathname || - [...fetchUrl.searchParams.keys()].length !== 1 || - fetchUrl.searchParams.get('uuid') !== fields['cacheId'] || - fetchUrl.search !== `?uuid=${encodeURIComponent(fields['cacheId'])}` || - fetchUrl.href !== fields['fetchUrl'] || - fetchUrl.href !== expected.href + urlPart(fetchUrl, 'protocol') !== 'https:' || + urlPart(fetchUrl, 'username') !== '' || + urlPart(fetchUrl, 'password') !== '' || + urlPart(fetchUrl, 'hash') !== '' || + urlPart(fetchUrl, 'origin') !== urlPart(base, 'origin') || + urlPart(fetchUrl, 'port') !== urlPart(base, 'port') || + urlPart(fetchUrl, 'pathname') !== urlPart(base, 'pathname') || + urlPart(fetchUrl, 'search') !== canonicalSearch || + urlPart(fetchUrl, 'href') !== fields['fetchUrl'] || + urlPart(fetchUrl, 'href') !== expectedHref ) { return undefined; } @@ -1687,7 +1845,7 @@ function readDirectCacheSource( function readSelectedCpm(value: unknown): number | undefined { const fields = exactFrozenDataRecord(value, ['selectedCpm']); const selectedCpm = fields?.['selectedCpm']; - return typeof selectedCpm === 'number' && Number.isFinite(selectedCpm) && selectedCpm >= 0 + return typeof selectedCpm === 'number' && isFiniteNumber(selectedCpm) && selectedCpm >= 0 ? selectedCpm : undefined; } @@ -1718,46 +1876,47 @@ function parseCacheAdm( if ( typeof value !== 'object' || value === null || - Array.isArray(value) || - Object.getPrototypeOf(value) !== Object.prototype || - Object.getOwnPropertySymbols(value).length !== 0 + isArray(value) || + objectPrototype(value) !== Object.prototype || + ownPropertySymbols(value).length !== 0 ) { return undefined; } const record = value as Record; - const names = Object.getOwnPropertyNames(record); + const names = ownPropertyNames(record); for (let index = 0; index < names.length; index += 1) { const name = names[index]; if (!name) return undefined; - const descriptor = Object.getOwnPropertyDescriptor(record, name); + const descriptor = ownPropertyDescriptor(record, name); if (!descriptor || !('value' in descriptor) || descriptor.enumerable !== true) { return undefined; } } - const hasOwn = (name: string): boolean => Object.prototype.hasOwnProperty.call(record, name); - if (hasOwn('width') || hasOwn('height')) return undefined; - const admDescriptor = Object.getOwnPropertyDescriptor(record, 'adm'); + const recordHasOwn = (name: string): boolean => hasOwn(record, name); + if (recordHasOwn('width') || recordHasOwn('height')) return undefined; + const admDescriptor = ownPropertyDescriptor(record, 'adm'); if ( !admDescriptor || !('value' in admDescriptor) || typeof admDescriptor.value !== 'string' || - admDescriptor.value.trim().length === 0 || - new TextEncoder().encode(admDescriptor.value).byteLength > MAX_CACHE_BODY_BYTES + (reflectApplyIntrinsic(stringTrimIntrinsic, admDescriptor.value, []) as string).length === + 0 || + utf8Length(admDescriptor.value) > MAX_CACHE_BODY_BYTES ) { return undefined; } - const hasWidth = hasOwn('w'); - const hasHeight = hasOwn('h'); + const hasWidth = recordHasOwn('w'); + const hasHeight = recordHasOwn('h'); if (hasWidth !== hasHeight) return undefined; if ( hasWidth && (typeof record['w'] !== 'number' || - !Number.isInteger(record['w']) || + !isInteger(record['w']) || record['w'] < 1 || record['w'] > 4096 || record['w'] !== source.width || typeof record['h'] !== 'number' || - !Number.isInteger(record['h']) || + !isInteger(record['h']) || record['h'] < 1 || record['h'] > 4096 || record['h'] !== source.height) @@ -1765,15 +1924,15 @@ function parseCacheAdm( return undefined; } if ( - hasOwn('price') && + recordHasOwn('price') && (typeof record['price'] !== 'number' || - !Number.isFinite(record['price']) || + !isFiniteNumber(record['price']) || record['price'] < 0) ) { return undefined; } const adm = expandAuctionPrice(admDescriptor.value, selectedCpm); - if (new TextEncoder().encode(adm).byteLength > MAX_CACHE_BODY_BYTES) return undefined; + if (utf8Length(adm) > MAX_CACHE_BODY_BYTES) return undefined; return frozen({ adm, height: source.height, @@ -1786,41 +1945,50 @@ function parseCacheAdm( } } -async function readCacheBody(response: CacheFetchResponse): Promise { +interface CacheBodyReadHooks { + readonly active: () => boolean; + readonly retain: ( + reader: CacheFetchReader, + cancel: NonNullable + ) => boolean; + readonly cancel: () => void; + readonly release: (reader: CacheFetchReader) => void; +} + +async function readCacheBody( + response: CacheFetchResponse, + hooks: CacheBodyReadHooks +): Promise { let reader: CacheFetchReader | undefined; - let cancel: CacheFetchReader['cancel']; let releaseLock: (() => void) | undefined; try { - if ( - response.type === 'error' || - response.type === 'opaque' || - response.type === 'opaqueredirect' - ) { - return frozen({ ok: false, reason: 'cache_network_error' }); - } if (!response.ok) return frozen({ ok: false, reason: 'cache_invalid_response' }); if (!response.body) return frozen({ ok: true, text: '' }); reader = response.body.getReader(); - cancel = reader.cancel; + const cancel = reader.cancel; + const read = reader.read; releaseLock = reader.releaseLock; - if (typeof reader.read !== 'function' || typeof cancel !== 'function') { + if ( + typeof read !== 'function' || + typeof cancel !== 'function' || + !hooks.active() || + !hooks.retain(reader, cancel) + ) { return frozen({ ok: false, reason: 'cache_network_error' }); } const chunks: Uint8Array[] = []; let total = 0; while (true) { - const step = await reader.read(); + if (!hooks.active()) return frozen({ ok: false, reason: 'cache_network_error' }); + const step = await reflectApplyIntrinsic(read, reader, []); + if (!hooks.active()) return frozen({ ok: false, reason: 'cache_network_error' }); if (step.done) break; if (!isUint8Array(step.value)) { return frozen({ ok: false, reason: 'cache_network_error' }); } total += step.value.byteLength; if (total > MAX_CACHE_BODY_BYTES) { - try { - await cancel.call(reader); - } catch { - // The byte limit is authoritative even if stream cancellation is hostile. - } + hooks.cancel(); return frozen({ ok: false, reason: 'cache_invalid_response' }); } arrayPush(chunks, step.value); @@ -1833,16 +2001,23 @@ async function readCacheBody(response: CacheFetchResponse): Promise; + const fields = reflectApplyIntrinsic(objectCreateIntrinsic, Object, [null]) as Record< + string, + unknown + >; for (const name of expected) { - const descriptor = Object.getOwnPropertyDescriptor(value, name); + const descriptor = ownPropertyDescriptor(value, name); if ( !descriptor || !('value' in descriptor) || @@ -1885,14 +2063,14 @@ function readDirectAdmSource(value: unknown): DirectAdmSource | undefined { fields['type'] !== 'adm' || fields['version'] !== 1 || typeof fields['adm'] !== 'string' || - fields['adm'].trim().length === 0 || - new TextEncoder().encode(fields['adm']).byteLength > 512 * 1024 || + (reflectApplyIntrinsic(stringTrimIntrinsic, fields['adm'], []) as string).length === 0 || + utf8Length(fields['adm']) > MAX_CACHE_BODY_BYTES || typeof fields['width'] !== 'number' || - !Number.isInteger(fields['width']) || + !isInteger(fields['width']) || fields['width'] < 1 || fields['width'] > 4096 || typeof fields['height'] !== 'number' || - !Number.isInteger(fields['height']) || + !isInteger(fields['height']) || fields['height'] < 1 || fields['height'] > 4096 ) { @@ -1943,12 +2121,8 @@ function renderAdmAttempt( return false; } if (admittedCacheAdm === undefined && !attempt.beginDirect()) return false; - let artifactKind: CommittedRenderArtifact['kind']; try { - const pathState = attempt.snapshot().state; - if (pathState === 'waiting_for_insertion') artifactKind = 'puc'; - else if (pathState === 'rendering_direct') artifactKind = 'direct_iframe'; - else return false; + if (attempt.snapshot().state !== 'rendering_direct') return false; } catch { attempt.fail('internal_error'); return false; @@ -2045,7 +2219,7 @@ function renderAdmAttempt( } const artifact = frozen({ - kind: artifactKind, + kind: 'direct_iframe', attemptId: attempt.id, slot: attempt.slot, navigationGeneration: attempt.navigationGeneration, @@ -2092,20 +2266,18 @@ export function renderDirectAdmAttempt(options: DirectAdmAttemptOptions): boolea return renderAdmAttempt(options); } -/** Fetch one admitted cache source, then enter the exact shared direct-ADM lifecycle. */ -export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): boolean { +/** Resolve one admitted cache source without assuming direct or PUC DOM ownership. */ +export function resolveCacheAdmAttempt(options: CacheAdmResolutionOptions): boolean { let attempt: RenderAttempt; - let cachePolicy: DirectCacheAttemptOptions['cachePolicy']; - let container: HTMLElement; - let fetchCache: DirectCacheAttemptOptions['fetcher']; - let prepareIframe: DirectAdmIframeConstructor; + let cachePolicy: CacheAdmResolutionOptions['cachePolicy']; + let fetchCache: CacheAdmResolutionOptions['fetcher']; + let onResolved: CacheAdmResolutionOptions['onResolved']; let publisherOrigin: string; try { attempt = options.attempt; cachePolicy = options.cachePolicy; - container = options.container; fetchCache = options.fetcher; - prepareIframe = options.prepareIframe; + onResolved = options.onResolved; publisherOrigin = options.publisherOrigin; } catch { return false; @@ -2113,48 +2285,89 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo if ( !weakSetHas(renderAttempts, attempt) || typeof fetchCache !== 'function' || - typeof prepareIframe !== 'function' + typeof onResolved !== 'function' ) { return false; } - - let exactDocumentOrigin: boolean; - try { - exactDocumentOrigin = - !!directAdmDocument && - typeof directAdmOwnerDocumentGetter === 'function' && - reflectApplyIntrinsic(directAdmOwnerDocumentGetter, container, []) === directAdmDocument && - directAdmDocument.defaultView?.location.origin === publisherOrigin; - } catch { - exactDocumentOrigin = false; - } - if (!exactDocumentOrigin) { - attempt.fail('winner_not_renderable'); - return false; - } + const cacheControls = weakMapGet(cacheAttemptControls, attempt); + if (!cacheControls) return false; const source = readDirectCacheSource(attempt.renderSource, cachePolicy); const winnerContext = attempt.winnerContext; const selectedCpm = readSelectedCpm(winnerContext); - if (!source || selectedCpm === undefined) { + let cacheOrigin: string | undefined; + try { + cacheOrigin = source + ? urlPart(Reflect.construct(urlIntrinsic, [source.fetchUrl]) as URL, 'origin') + : undefined; + } catch { + cacheOrigin = undefined; + } + if (!source || selectedCpm === undefined || cacheOrigin === undefined) { attempt.fail('descriptor_invalid'); return false; } - if (!attempt.beginCacheFetch()) return false; + if (reflectApplyIntrinsic(cacheControls.begin, cacheControls, []) !== true) return false; let controller: AbortController; + let signal: AbortSignal; try { - controller = new AbortController(); + controller = Reflect.construct(abortControllerIntrinsic, []) as AbortController; + signal = reflectApplyIntrinsic(abortControllerSignalGetter, controller, []) as AbortSignal; } catch { attempt.fail('cache_network_error'); return false; } let pending = true; + let activeReader: CacheFetchReader | undefined; + let activeReaderCancel: NonNullable | undefined; + let activeReaderCancelled = false; + const retainBodyReader = ( + reader: CacheFetchReader, + cancel: NonNullable + ): boolean => { + if (!pending || activeReader !== undefined) return false; + activeReader = reader; + activeReaderCancel = cancel; + activeReaderCancelled = false; + return true; + }; + const releaseBodyReader = (reader: CacheFetchReader): void => { + if (activeReader !== reader) return; + activeReader = undefined; + activeReaderCancel = undefined; + activeReaderCancelled = false; + }; + const cancelBodyReader = (): void => { + const reader = activeReader; + const cancel = activeReaderCancel; + if (!reader || !cancel || activeReaderCancelled) return; + activeReaderCancelled = true; + try { + const cancellation = reflectApplyIntrinsic(cancel, reader, []) as unknown; + if ( + (typeof cancellation === 'object' || typeof cancellation === 'function') && + cancellation !== null + ) { + try { + reflectApplyIntrinsic(promiseThenIntrinsic, cancellation, [ + ignoreAsyncDisposal, + ignoreAsyncDisposal, + ]); + } catch { + // Cancellation authority is exact-once even for a hostile promise boundary. + } + } + } catch { + // Terminal settlement remains authoritative if reader cancellation throws. + } + }; const abortFetch = (): void => { - if (controller.signal.aborted) return; + cancelBodyReader(); try { - controller.abort(); + if (reflectApplyIntrinsic(abortSignalAbortedGetter, signal, []) === true) return; + reflectApplyIntrinsic(abortControllerAbortIntrinsic, controller, []); } catch { // Abort is best-effort after the attempt has already settled. } @@ -2191,7 +2404,7 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo redirect: 'error', referrer: '', referrerPolicy: 'no-referrer', - signal: controller.signal, + signal, } satisfies RequestInit, ]) as Promise; } catch { @@ -2210,7 +2423,7 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo if (!pending) return; let response: CacheFetchResponse; let responseOk: boolean; - let responseType: Response['type'] | undefined; + let responseType: Response['type']; try { if ((typeof fetched !== 'object' && typeof fetched !== 'function') || fetched === null) { throw new TypeError('invalid cache response'); @@ -2218,16 +2431,15 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo response = fetched as CacheFetchResponse; responseOk = response.ok; responseType = response.type; - if (typeof responseOk !== 'boolean') throw new TypeError('invalid cache status'); + if (typeof responseOk !== 'boolean' || typeof responseType !== 'string') { + throw new TypeError('invalid cache response metadata'); + } } catch { failCache('cache_network_error'); return; } - if ( - responseType === 'error' || - responseType === 'opaque' || - responseType === 'opaqueredirect' - ) { + const expectedResponseType = cacheOrigin === publisherOrigin ? 'basic' : 'cors'; + if (responseType !== expectedResponseType) { failCache('cache_network_error'); return; } @@ -2235,13 +2447,18 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo failCache('cache_http_error'); return; } - const body = await readCacheBody(response); + const body = await readCacheBody(response, { + active: () => pending, + cancel: cancelBodyReader, + release: releaseBodyReader, + retain: retainBodyReader, + }); if (!pending) return; if (!body.ok) { failCache(body.reason); return; } - if (!attempt.cacheFetchCompleted()) { + if (reflectApplyIntrinsic(cacheControls.complete, cacheControls, []) !== true) { failCache('cache_network_error'); return; } @@ -2255,15 +2472,18 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo return; } pending = false; + let resolved: boolean; try { - if ( - !renderAdmAttempt({ attempt, container, prepareIframe, publisherOrigin }, admSource) && - attempt.snapshot().outcome === undefined - ) { - attempt.fail('internal_error'); - } + resolved = reflectApplyIntrinsic(onResolved, undefined, [admSource]) === true; } catch { - attempt.fail('internal_error'); + resolved = false; + } + if (!resolved) { + try { + if (attempt.snapshot().outcome === undefined) attempt.fail('internal_error'); + } catch { + // The terminal latch remains authoritative across a hostile consumer boundary. + } } }; const completion = complete(); @@ -2279,6 +2499,59 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo return true; } +/** Fetch and render one admitted direct cache source through the shared ADM constructor. */ +export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): boolean { + let attempt: RenderAttempt; + let cachePolicy: DirectCacheAttemptOptions['cachePolicy']; + let container: HTMLElement; + let fetcher: DirectCacheAttemptOptions['fetcher']; + let prepareIframe: DirectAdmIframeConstructor; + let publisherOrigin: string; + try { + attempt = options.attempt; + cachePolicy = options.cachePolicy; + container = options.container; + fetcher = options.fetcher; + prepareIframe = options.prepareIframe; + publisherOrigin = options.publisherOrigin; + } catch { + return false; + } + if (!weakSetHas(renderAttempts, attempt) || typeof prepareIframe !== 'function') return false; + + let created = false; + let exactDirectContainer: boolean; + try { + created = attempt.snapshot().state === 'created'; + exactDirectContainer = + created && + !!directAdmDocument && + typeof directAdmOwnerDocumentGetter === 'function' && + reflectApplyIntrinsic(directAdmOwnerDocumentGetter, container, []) === directAdmDocument && + directAdmDocument.defaultView?.location.origin === publisherOrigin; + } catch { + exactDirectContainer = false; + } + if (!created) return false; + if (!exactDirectContainer) { + try { + attempt.fail('winner_not_renderable'); + } catch { + // The exact direct-container boundary fails closed. + } + return false; + } + + return resolveCacheAdmAttempt({ + attempt, + cachePolicy, + fetcher, + onResolved: (source) => + renderAdmAttempt({ attempt, container, prepareIframe, publisherOrigin }, source), + publisherOrigin, + }); +} + interface RendererNonceBinding { readonly nonce: string; readonly attempt: RenderAttempt; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 7152c697e..485109a14 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -22,6 +22,7 @@ import { createTestBrowserRuntimeComposition, } from '../../src/composition/browser'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import type { RenderAttempt } from '../../src/services/render'; function createTarget() { return { @@ -654,6 +655,66 @@ describe('browser composition', () => { 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: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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: { + bridgeRecognizer: vi.fn(), + 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(() => ({ 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 120bb43ac..42741b536 100644 --- a/crates/trusted-server-js/lib/test/core/config.test.ts +++ b/crates/trusted-server-js/lib/test/core/config.test.ts @@ -37,6 +37,41 @@ describe('config', () => { 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.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); + + if (targetBytes <= 4_096) { + expect(parseCacheFetchPolicyV1({ version: 1, baseUrl })).toEqual({ + version: 1, + baseUrl, + }); + } else { + expect(parseCacheFetchPolicyV1({ version: 1, baseUrl })).toBeUndefined(); + } + } + ); + 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 }; diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index ce529a5ad..89b70807c 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -19,6 +19,8 @@ import { createSlotOperation, renderDirectCacheAttempt, renderDirectAdmAttempt, + resolveCacheAdmAttempt, + type CacheAdmSource, type CommittedRenderArtifact, type DirectAdmIframeConstructor, type DirectAdmIframeHandle, @@ -149,20 +151,24 @@ function owner( }, isCurrent: () => current && !disposed, prepareWinnerContext: (context: WinnerContext) => { - if (!scope.isCurrent() || winnerContext !== undefined) return undefined; + 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 !== undefined) return false; + if (!scope.isCurrent() || winnerContext !== previous) return false; winnerContext = context; committed = true; return true; }, rollback: () => { - if (committed && winnerContext === context) winnerContext = undefined; + if (committed && previous === undefined && winnerContext === context) { + winnerContext = undefined; + } committed = false; - return winnerContext === undefined; + return winnerContext === previous; }, }); }, @@ -2024,29 +2030,107 @@ describe('direct cache attempt rendering', () => { document.body.innerHTML = ''; }); - it('accepts a same-origin basic response because request mode enforces CORS', async () => { - document.body.innerHTML = '
'; + it.each(['basic', 'default', undefined] as const)( + 'rejects a cross-origin 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('accepts a basic response only when the cache and publisher origins match', async () => { const render = attempt(); expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); - const container = document.getElementById('fictional-slot')!; + 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, + publisherOrigin: new URL(CACHE_SOURCE.fetchUrl).origin, + }) + ).toBe(true); + await vi.waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); + expect(onResolved.mock.calls[0]?.[0]).toMatchObject({ adm: '
same origin
' }); + expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'rendering_direct' }); + expect(render.cancel('caller_aborted')).toBe(true); + }); + + it('rejects 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, + publisherOrigin: new URL(CACHE_SOURCE.fetchUrl).origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }) + ); + expect(onResolved).not.toHaveBeenCalled(); + }); + + 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(); - const basicResponse = new Response(JSON.stringify({ adm: '
cached
' })); - Object.defineProperty(basicResponse, 'type', { configurable: true, value: 'basic' }); expect( renderDirectCacheAttempt({ attempt: render, cachePolicy: CACHE_POLICY, - container, - fetcher: async () => basicResponse, + container: foreignContainer, + fetcher, prepareIframe: prepareAdmIframe, 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 = ''; + ).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 () => { @@ -2215,7 +2299,7 @@ describe('direct cache attempt rendering', () => { document.body.innerHTML = ''; }); - it('renders a delayed owner-controlled cache claim as one PUC artifact', async () => { + it('resolves a delayed owner-controlled cache claim without constructing publisher DOM', async () => { document.body.innerHTML = '
placeholder
'; const scope = owner(); const artifacts = createCommittedArtifactStore(); @@ -2232,14 +2316,14 @@ describe('direct cache attempt rendering', () => { }) ); const container = document.getElementById('fictional-slot')!; + const onResolved = vi.fn((_source: unknown) => true); expect( - renderDirectCacheAttempt({ + resolveCacheAdmAttempt({ attempt: render, cachePolicy: CACHE_POLICY, - container, fetcher: fetchCache, - prepareIframe: prepareAdmIframe, + onResolved, publisherOrigin: window.location.origin, }) ).toBe(true); @@ -2252,21 +2336,194 @@ describe('direct cache attempt rendering', () => { resolveFetch?.( corsResponse(JSON.stringify({ adm: '
${AUCTION_PRICE}
', price: 9000 })) ); - const frame = await insertedCacheFrame(container, render); - expect(frame.srcdoc).toContain('
1
'); - expect(frame.srcdoc).not.toContain('9000'); - - if (render.snapshot().outcome === undefined) frame.dispatchEvent(new Event('load')); - expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); - expect(artifacts.current('fictional-slot')).toMatchObject({ - attemptId: render.id, - kind: 'puc', + 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(container.querySelector('span')).toBeNull(); + 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, + publisherOrigin: window.location.origin, + }) + ).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, + publisherOrigin: window.location.origin, + }) + ).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
' }])], @@ -2304,6 +2561,100 @@ describe('direct cache attempt rendering', () => { 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(); @@ -2330,6 +2681,60 @@ describe('direct cache attempt rendering', () => { 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(); @@ -2378,6 +2783,41 @@ describe('direct cache attempt rendering', () => { 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({ @@ -2422,6 +2862,135 @@ describe('direct cache attempt rendering', () => { 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 = '
'; @@ -2447,7 +3016,10 @@ describe('direct cache attempt rendering', () => { publisherOrigin: window.location.origin, }) ).toBe(true); - await vi.advanceTimersByTimeAsync(5_000); + 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', diff --git a/crates/trusted-server-js/lib/test/services/reservations.test.ts b/crates/trusted-server-js/lib/test/services/reservations.test.ts index b37dc0342..b7446c5ad 100644 --- a/crates/trusted-server-js/lib/test/services/reservations.test.ts +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -1908,7 +1908,7 @@ describe('atomic claims and disposal', () => { expect(attempt.winnerContext).toBeUndefined(); expect(service.snapshotInventoryForTest().entriesWithPucSource).toBe(0); }); - it('preserves one cache source and immutable context after projection replacement', () => { + it('preserves one cache source and immutable context after registration input mutation', () => { const { navigation } = runtimeNavigation(); const attempt = renderAttempt(navigation); const service = serviceAt(() => 0); From d8a534042f8c735b1a61b3d40c47ef6be3708402 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:04:10 -0700 Subject: [PATCH 057/194] Implement the Universal Creative bridge lifecycle --- .../lib/src/adapters/messaging.ts | 97 +- .../lib/src/composition/browser.ts | 75 +- .../lib/src/services/puc_bridge.ts | 2226 ++++++++++++++++- .../lib/test/adapters/messaging.test.ts | 26 + .../lib/test/composition/browser.test.ts | 28 +- .../lib/test/services/puc_bridge.test.ts | 2085 ++++++++++++++- ...8-04-aps-tsjs-resilience-implementation.md | 24 +- ...s-render-fix-and-tsjs-resilience-design.md | 13 +- 8 files changed, 4484 insertions(+), 90 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/messaging.ts b/crates/trusted-server-js/lib/src/adapters/messaging.ts index ff50f86cd..eb8f9c425 100644 --- a/crates/trusted-server-js/lib/src/adapters/messaging.ts +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -234,6 +234,13 @@ export interface MessagingAdapter { 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. */ @@ -1053,9 +1060,14 @@ function wrapPort(raw: RawPort, transferable = false): MessagingPort { return port; } -function snapshotPortArray( - candidate: unknown -): { readonly valid: boolean; readonly values: readonly unknown[] } | undefined { +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; @@ -1073,7 +1085,8 @@ function snapshotPortArray( const length = lengthDescriptor.value; const ownKeys = Reflect.ownKeys(candidate); const values: unknown[] = []; - let valid = length <= 2 && ownKeys.length === length + 1; + 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]; @@ -1085,11 +1098,15 @@ function snapshotPortArray( break; } } - if (!expected) valid = false; + 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; } @@ -1098,18 +1115,25 @@ function snapshotPortArray( } else { for (let keyIndex = 0; keyIndex < ownKeys.length; keyIndex += 1) { const key = ownKeys[keyIndex]; - if (typeof key !== 'string' || key === 'length') continue; + 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 { valid, values }; + return { exactShape, originalCount: length, valid, values }; } catch { return undefined; } @@ -1155,9 +1179,10 @@ function commitTransferReservation(reservation: TransferReservation): void { } } -function extractTransferredPorts( +function extractTransferredPortsInRange( event: unknown, - expectedCount: 0 | 1 | 2 + minimumCount: 0 | 1 | 2, + maximumCount: 0 | 1 | 2 ): readonly MessagingPort[] | undefined { let candidates: unknown; try { @@ -1170,7 +1195,10 @@ function extractTransferredPorts( if (!snapshot) return undefined; const inspections: Array = []; const claimed: boolean[] = []; - let accepted = snapshot.valid && snapshot.values.length === expectedCount; + 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); @@ -1209,6 +1237,53 @@ function extractTransferredPorts( } } +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; @@ -1348,6 +1423,7 @@ export function createBrowserMessagingAdapter( parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => parseProtocolMessage(kind, candidate, validation), extractTransferredPorts, + inspectTransferredPorts, }); } @@ -1361,5 +1437,6 @@ export function createNoopMessagingAdapter(): MessagingAdapter { parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => parseProtocolMessage(kind, candidate, {}), extractTransferredPorts, + inspectTransferredPorts, }); } diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index eef7e1967..6f97ff093 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -24,7 +24,7 @@ import { } from '../core/contracts/auction_projection'; import { validateApsRenderer } from '../core/contracts/aps_renderer'; import { prepareAdmIframe } from '../core/render'; -import { renderDirectApsAttempt } from '../integrations/aps/render'; +import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; import { createRuntimeSession } from '../kernel/sessions'; @@ -39,11 +39,13 @@ import { import { createReservationService, type ReservationService } from '../services/reservations'; import { createRendererNonceRegistry, + resolveCacheAdmAttempt, renderDirectCacheAttempt, renderDirectAdmAttempt, type RenderAttempt, type RendererNonceRegistry, } from '../services/render'; +import { createPucBridge, type PucBridge, type PucBridgeOptions } from '../services/puc_bridge'; import { createSlotService, type SlotService } from '../services/slots'; import { createTargetingService, type TargetingService } from '../services/targeting'; @@ -58,6 +60,7 @@ export interface BrowserComposition { } export interface BrowserServices { + readonly pucBridge: PucBridge; readonly reservations: ReservationService; readonly rendererNonces: RendererNonceRegistry; readonly renderDirectAdm: (attempt: RenderAttempt, container: HTMLElement) => boolean; @@ -93,13 +96,11 @@ export interface BrowserRuntimeComposition extends BrowserComposition { 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; } export interface BrowserCoreActivations { - readonly bridgeRecognizer: ( - context: CoreActivationContext, - adapters: Readonly - ) => void; readonly correctnessGptListeners: ( context: CoreActivationContext, adapters: Readonly, @@ -121,6 +122,13 @@ interface AcceptedBrowserBoot { }; } +interface PreparedBrowserServices { + readonly publisherOrigin: string; + readonly rendererUrl: string; + readonly resolveCacheAdm: NonNullable; + readonly services: Readonly>; +} + function projectionSlots(projection: object): readonly string[] { const accepted = projection as { readonly auction: { readonly results: readonly { readonly slot: string }[] }; @@ -197,6 +205,7 @@ export function createTestBrowserRuntimeComposition( ): BrowserRuntimeComposition { const composition = createBrowserComposition(compositionOptions); let runtimeSession: RuntimeSession | undefined; + let preparedBrowserServices: PreparedBrowserServices | undefined; let browserServices: Readonly | undefined; let auctionContextRegistry: AuctionContextRegistry | undefined; let projectionParser: ((candidate: unknown) => object | undefined) | undefined; @@ -221,6 +230,7 @@ export function createTestBrowserRuntimeComposition( const rendererNonces = createRendererNonceRegistry(); const publisherOrigin = window.location.origin; const fetchCache = globalThis.fetch; + const rendererUrl = new URL(APS_RENDERER_V1_PATH, publisherOrigin).href; const renderDirectAdm = (attempt: RenderAttempt, container: HTMLElement): boolean => { try { return renderDirectAdmAttempt({ @@ -276,6 +286,38 @@ export function createTestBrowserRuntimeComposition( 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, + publisherOrigin, + }); + } catch { + return false; + } + }; const services = Object.freeze({ reservations: reservationService, rendererNonces, @@ -285,6 +327,12 @@ export function createTestBrowserRuntimeComposition( slots: slotService, targeting: targetingService, }); + preparedBrowserServices = Object.freeze({ + publisherOrigin, + rendererUrl, + resolveCacheAdm, + services, + }); const session = createRuntimeSession({ createIdentityIssuer: compositionOptions.createIdentityIssuerForTest ?? createBrowserNavigationIdentityIssuer, @@ -300,6 +348,7 @@ export function createTestBrowserRuntimeComposition( composition.adapters.prebid.dispose(); if (runtimeSession === session) { runtimeSession = undefined; + preparedBrowserServices = undefined; browserServices = undefined; auctionContextRegistry = undefined; projectionParser = undefined; @@ -326,14 +375,23 @@ export function createTestBrowserRuntimeComposition( runtimeOwner: session, }); runtimeSession = session; - browserServices = services; auctionContextRegistry = contextRegistry; projectionParser = parseProjection; return runtimeOptions.activateOwner?.(context); }, activateCore: (context) => { - if (!browserServices) throw new Error('Browser services are unavailable'); - compositionOptions.coreActivations.bridgeRecognizer(context, composition.adapters); + const prepared = preparedBrowserServices; + if (!prepared) throw new Error('Browser services are unavailable'); + const pucBridge = createPucBridge({ + messaging: composition.adapters.messaging, + publisherOrigin: prepared.publisherOrigin, + rendererNonces: prepared.services.rendererNonces, + rendererUrl: prepared.rendererUrl, + reservations: prepared.services.reservations, + resolveCacheAdm: prepared.resolveCacheAdm, + }); + context.onDispose(() => pucBridge.dispose()); + browserServices = Object.freeze({ ...prepared.services, pucBridge }); browserServices.slots.activate(); compositionOptions.coreActivations.correctnessGptListeners( context, @@ -362,5 +420,6 @@ export function createTestBrowserRuntimeComposition( targetingServiceForTest: () => browserServices?.targeting, reservationServiceForTest: () => browserServices?.reservations, rendererNonceRegistryForTest: () => browserServices?.rendererNonces, + pucBridgeForTest: () => browserServices?.pucBridge, }); } diff --git a/crates/trusted-server-js/lib/src/services/puc_bridge.ts b/crates/trusted-server-js/lib/src/services/puc_bridge.ts index be4772c05..91e0d6d67 100644 --- a/crates/trusted-server-js/lib/src/services/puc_bridge.ts +++ b/crates/trusted-server-js/lib/src/services/puc_bridge.ts @@ -3,42 +3,768 @@ import { type MessagingAdapter, type MessagingPort, } from '../adapters/messaging'; +import { mintBrowserLifecycleTicket } from '../kernel/identity'; +import type { IdentityGenerationResult } from '../kernel/identity'; -import type { ReservationRecognition, ReservationService } from './reservations'; +import type { + CacheAdmSource, + CommittedRenderArtifact, + RenderAttempt, + RenderFailureReason, + RenderOutcome, + RendererNonceRegistry, +} from './render'; +import type { + ReservationAttempt, + ReservationRecognition, + ReservationRenderSource, + ReservationService, +} from './reservations'; +const CLAIM_DEADLINE_MS = 3_000; +const LIFECYCLE_TICKET_TTL_MS = 3_000; +const MAX_DYNAMIC_OWNER_BYTES = 64 * 1_024; +const MAX_OUTER_RESPONSE_BYTES = 72 * 1_024; +const MAX_TICKET_DRAWS = 8; +const MAX_TICKETS = 320; +const RESERVATION_ID = /^r1_[A-Za-z0-9_-]{22}$/; +const ATTEMPT_ID = /^a1_[A-Za-z0-9_-]{22}$/; +const LIFECYCLE_TICKET = /^t1_[A-Za-z0-9_-]{22}$/; +const textEncoder = new TextEncoder(); +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; const mapGetIntrinsic = Map.prototype.get; const mapSetIntrinsic = Map.prototype.set; +const mapDeleteIntrinsic = Map.prototype.delete; const mapClearIntrinsic = Map.prototype.clear; +const mapEntriesIntrinsic = Map.prototype.entries; const mapSizeGetter = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.get as ( this: Map ) => number; const mapValuesIntrinsic = Map.prototype.values; +const mapEntryIteratorNextIntrinsic = Object.getPrototypeOf(new Map().entries()).next as ( + this: IterableIterator +) => IteratorResult; const mapIteratorNextIntrinsic = Object.getPrototypeOf(new Map().values()).next as ( this: IterableIterator ) => IteratorResult; const jsonStringifyIntrinsic = JSON.stringify; const objectFreezeIntrinsic = Object.freeze; +/** + * Install the self-contained renderer that PUC evaluates in its hidden frame. + * + * This function deliberately closes over nothing: its serialized source is the + * exact program returned in the successful outer PUC response. + */ +function installPucDynamicOwner(): void { + const ownerWindow = window as Window & { + render?: (data: unknown, helper: unknown, creativeWindow: Window) => Promise; + }; + const ticketPattern = /^t1_[A-Za-z0-9_-]{22}$/; + const reservationPattern = /^r1_[A-Za-z0-9_-]{22}$/; + const admSandbox = + 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; + const apsSandbox = + 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; + 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', + ]); + const cancellationReasons = new Set(['caller_aborted', 'superseded', 'navigation_disposed']); + + const ownDataValue = (candidate: unknown, name: string): unknown => { + try { + if (typeof candidate !== 'object' || candidate === null) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(candidate, name); + return descriptor && 'value' in descriptor ? descriptor.value : undefined; + } catch { + return undefined; + } + }; + + const exactRecord = ( + candidate: unknown, + keys: readonly string[] + ): Record | undefined => { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + Object.getOwnPropertySymbols(candidate).length !== 0 + ) { + return undefined; + } + const names = Object.getOwnPropertyNames(candidate).sort(); + const expected = [...keys].sort(); + if (names.length !== expected.length) return undefined; + for (let index = 0; index < expected.length; index += 1) { + if (names[index] !== expected[index]) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(candidate, expected[index] as string); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + } + return candidate as Record; + }; + const eventPorts = (event: unknown, count: number): MessagePort[] | undefined => { + try { + if (typeof event !== 'object' || event === null) return undefined; + const ports = Reflect.get(event, 'ports') as unknown; + if (!Array.isArray(ports) || ports.length !== count) return undefined; + for (let index = 0; index < ports.length; index += 1) { + const port = ports[index] as Partial | undefined; + if (!port || typeof port.postMessage !== 'function' || typeof port.close !== 'function') { + return undefined; + } + } + return ports as MessagePort[]; + } catch { + return undefined; + } + }; + const closeEventPorts = (event: unknown): void => { + try { + if (typeof event !== 'object' || event === null) return; + const ports = Reflect.get(event, 'ports') as unknown; + if (!Array.isArray(ports)) return; + for (let index = 0; index < ports.length; index += 1) { + try { + const port = ports[index] as Partial | undefined; + if (typeof port?.close === 'function') port.close(); + } catch { + // Late or malformed endpoints are still contained independently. + } + } + } catch { + // A hostile event cannot interrupt terminal cleanup. + } + }; + const parseRegistration = (value: unknown): Record | undefined => { + try { + if (typeof value !== 'string' || new TextEncoder().encode(value).byteLength > 4096) { + return undefined; + } + return exactRecord(JSON.parse(value) as unknown, [ + 'message', + 'adId', + 'version', + 'lifecycleTicket', + ]); + } catch { + return undefined; + } + }; + const validDimension = (value: unknown): value is number => + typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 4096; + const validAdmSource = (value: unknown): value is Record => { + const source = exactRecord(value, ['type', 'version', 'adm', 'width', 'height']); + if ( + !source || + source['type'] !== 'adm' || + source['version'] !== 1 || + typeof source['adm'] !== 'string' || + source['adm'].trim().length === 0 || + new TextEncoder().encode(source['adm']).byteLength > 512 * 1024 || + !validDimension(source['width']) || + !validDimension(source['height']) + ) { + return false; + } + return true; + }; + + ownerWindow.render = (data, helper, creativeWindow) => + new Promise((resolve, reject) => { + let outer: Record | undefined; + let owner: Record | undefined; + let sendMessage: unknown; + try { + outer = exactRecord(data, ['adId', 'message', 'renderer', 'rendererVersion', 'tsOwner']); + owner = outer + ? exactRecord(outer['tsOwner'], ['version', 'status', 'kind', 'lifecycleTicket']) + : undefined; + sendMessage = + typeof helper === 'object' && helper !== null + ? Reflect.get(helper, 'sendMessage') + : undefined; + } catch { + outer = undefined; + } + const adId = outer?.['adId']; + const lifecycleTicket = owner?.['lifecycleTicket']; + if ( + !outer || + !owner || + outer['message'] !== 'Prebid Response' || + outer['rendererVersion'] !== '3' || + typeof adId !== 'string' || + !reservationPattern.test(adId) || + owner['version'] !== 1 || + owner['status'] !== 'ready' || + (owner['kind'] !== 'aps' && owner['kind'] !== 'adm') || + typeof lifecycleTicket !== 'string' || + !ticketPattern.test(lifecycleTicket) || + typeof sendMessage !== 'function' || + !creativeWindow || + !creativeWindow.document + ) { + reject(new Error('TS render owner input refused')); + return; + } + + let settled = false; + let registrationFinished = false; + let helperDisposer: (() => void) | undefined; + let ownerTimer: number | undefined; + let controlPort: MessagePort | undefined; + let documentPort: MessagePort | undefined; + let frame: HTMLIFrameElement | undefined; + let frameCommitted = false; + let localApsFailure = false; + let started = false; + + const removeFrameHandlers = (): void => { + if (!frame) return; + frame.onload = null; + frame.onerror = null; + }; + const closePort = (port: MessagePort | undefined): void => { + try { + port?.close(); + } catch { + // Endpoint cleanup remains best-effort after the owner is inert. + } + }; + const stopHelper = (): void => { + const dispose = helperDisposer; + helperDisposer = undefined; + try { + dispose?.(); + } catch { + // PUC helper cleanup cannot replay owner settlement. + } + }; + const finish = (accepted: boolean, reason: string): void => { + if (settled) return; + settled = true; + if (registrationTimer !== undefined) creativeWindow.clearTimeout(registrationTimer); + if (ownerTimer !== undefined) creativeWindow.clearTimeout(ownerTimer); + stopHelper(); + removeFrameHandlers(); + if (!accepted && frame && !frameCommitted) frame.remove(); + if (controlPort) { + controlPort.onmessage = null; + controlPort.onmessageerror = null; + } + closePort(documentPort); + closePort(controlPort); + documentPort = undefined; + controlPort = undefined; + if (accepted) resolve(); + else reject(new Error(reason)); + }; + const postControl = (message: Record): boolean => { + try { + if (!controlPort || settled) return false; + controlPort.postMessage(message); + return true; + } catch { + finish(false, 'TS render owner control post failed'); + return false; + } + }; + const configureFrame = ( + source: Record, + sandbox: string + ): HTMLIFrameElement => { + const width = source['width'] as number; + const height = source['height'] as number; + const next = creativeWindow.document.createElement('iframe'); + next.setAttribute('sandbox', sandbox); + next.setAttribute('referrerpolicy', 'no-referrer'); + next.setAttribute('width', String(width)); + next.setAttribute('height', String(height)); + next.setAttribute('scrolling', 'no'); + next.setAttribute('frameborder', '0'); + next.setAttribute('marginwidth', '0'); + next.setAttribute('marginheight', '0'); + next.setAttribute('title', 'Ad content'); + next.setAttribute('aria-label', 'Advertisement'); + next.setAttribute( + 'style', + `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` + ); + return next; + }; + const prepareDocument = (): void => { + const document = creativeWindow.document; + document.documentElement.style.margin = '0'; + document.documentElement.style.padding = '0'; + document.documentElement.style.overflow = 'hidden'; + if (document.body) { + document.body.style.margin = '0'; + document.body.style.padding = '0'; + document.body.style.overflow = 'hidden'; + } + }; + const insertAdm = (source: Record): void => { + if (!validAdmSource(source) || !creativeWindow.document.body) { + finish(false, 'TS ADM source refused'); + return; + } + prepareDocument(); + const next = configureFrame(source, admSandbox); + next.onload = () => { + if (!settled && frame === next && next.isConnected) { + postControl({ + message: 'TS ADM Loaded', + version: 1, + lifecycleTicket, + }); + } + }; + next.onerror = () => { + if (!settled && frame === next) { + postControl({ + message: 'TS ADM Failed', + version: 1, + lifecycleTicket, + }); + } + }; + next.srcdoc = `${source['adm'] as string}`; + frame = next; + creativeWindow.document.body.appendChild(next); + postControl({ + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket, + }); + }; + const insertAps = (start: Record, ports: MessagePort[]): void => { + const envelope = exactRecord(start['envelope'], [ + 'version', + 'nonce', + 'publisherOrigin', + 'renderer', + ]); + const rendererCandidate = envelope?.['renderer']; + const renderer = envelope + ? exactRecord(rendererCandidate, [ + 'type', + 'version', + 'accountId', + 'bidId', + 'tagType', + 'creativeUrl', + 'width', + 'height', + 'aaxResponse', + ...(typeof rendererCandidate === 'object' && + rendererCandidate !== null && + Object.prototype.hasOwnProperty.call(rendererCandidate, 'creativeId') + ? ['creativeId'] + : []), + ]) + : undefined; + const rendererUrl = start['rendererUrl']; + let parsedUrl: URL | undefined; + let parsedPublisherOrigin: URL | undefined; + try { + parsedUrl = typeof rendererUrl === 'string' ? new URL(rendererUrl) : undefined; + parsedPublisherOrigin = + typeof envelope?.['publisherOrigin'] === 'string' + ? new URL(envelope['publisherOrigin']) + : undefined; + } catch { + parsedUrl = undefined; + parsedPublisherOrigin = undefined; + } + if ( + !envelope || + !renderer || + envelope['version'] !== 1 || + typeof envelope['nonce'] !== 'string' || + !/^n1_[A-Za-z0-9_-]{22}$/.test(envelope['nonce']) || + typeof envelope['publisherOrigin'] !== 'string' || + new TextEncoder().encode(envelope['publisherOrigin']).byteLength > 2048 || + renderer['type'] !== 'aps' || + renderer['version'] !== 1 || + !validDimension(renderer['width']) || + !validDimension(renderer['height']) || + !parsedUrl || + !parsedPublisherOrigin || + new TextEncoder().encode(String(rendererUrl)).byteLength > 2048 || + (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') || + parsedUrl.hostname === '' || + parsedUrl.username !== '' || + parsedUrl.password !== '' || + parsedUrl.pathname !== '/integrations/aps/renderer/v1' || + parsedUrl.search !== '' || + parsedUrl.hash !== '' || + (parsedPublisherOrigin.protocol !== 'https:' && + parsedPublisherOrigin.protocol !== 'http:') || + parsedPublisherOrigin.hostname === '' || + parsedPublisherOrigin.username !== '' || + parsedPublisherOrigin.password !== '' || + parsedPublisherOrigin.origin !== envelope['publisherOrigin'] || + parsedPublisherOrigin.pathname !== '/' || + parsedPublisherOrigin.search !== '' || + parsedPublisherOrigin.hash !== '' || + parsedUrl.origin !== parsedPublisherOrigin.origin || + ports.length !== 1 || + !creativeWindow.document.body + ) { + closePort(ports[0]); + finish(false, 'TS APS start refused'); + return; + } + prepareDocument(); + documentPort = ports[0]; + const next = configureFrame(renderer, apsSandbox); + const containLocalFailure = (transferred?: MessagePort): void => { + localApsFailure = true; + next.onload = null; + next.onerror = null; + closePort(transferred); + if (documentPort) { + closePort(documentPort); + documentPort = undefined; + } + next.remove(); + }; + next.onload = () => { + if (settled || frame !== next || !next.isConnected || !documentPort) return; + const transferred = documentPort; + documentPort = undefined; + try { + const target = next.contentWindow; + if (!target) throw new Error('APS document target is unavailable'); + target.postMessage(envelope, '*', [transferred]); + } catch { + containLocalFailure(transferred); + } + }; + next.onerror = () => containLocalFailure(); + next.src = `${parsedUrl.href}#tsaps=${envelope['nonce'] as string}`; + frame = next; + creativeWindow.document.body.appendChild(next); + postControl({ + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket, + }); + }; + const receiveControl = (event: MessageEvent): void => { + if (settled) { + closeEventPorts(event); + return; + } + const ports = eventPorts(event, 0) ?? eventPorts(event, 1); + const dataValue = ownDataValue(event, 'data'); + const routedMessage = ownDataValue(dataValue, 'message'); + const routedOutcome = ownDataValue(dataValue, 'outcome'); + const message = exactRecord(dataValue, [ + 'message', + 'version', + 'lifecycleTicket', + ...(routedMessage === 'TS APS Start' + ? ['rendererUrl', 'envelope'] + : routedMessage === 'TS ADM Start' + ? ['source'] + : routedMessage === 'TS Owner Settled' && routedOutcome !== undefined + ? routedOutcome === 'accepted' + ? ['outcome'] + : ['outcome', 'reason'] + : []), + ]); + if ( + !message || + !ports || + message['version'] !== 1 || + message['lifecycleTicket'] !== lifecycleTicket + ) { + closeEventPorts(event); + finish(false, 'TS render owner control refused'); + return; + } + if (message['message'] === 'TS ADM Start' && ports.length === 0 && !started) { + started = true; + insertAdm(message['source'] as Record); + return; + } + if (message['message'] === 'TS APS Start' && ports.length === 1 && !started) { + started = true; + insertAps(message, ports); + return; + } + if (message['message'] === 'TS Owner Settled' && ports.length === 0) { + if (message['outcome'] === 'accepted' && !localApsFailure && frame && frame.isConnected) { + frameCommitted = true; + finish(true, ''); + return; + } + if ( + message['outcome'] === 'failed' && + typeof message['reason'] === 'string' && + renderFailureReasons.has(message['reason']) + ) { + finish(false, message['reason']); + return; + } + if ( + message['outcome'] === 'cancelled' && + typeof message['reason'] === 'string' && + cancellationReasons.has(message['reason']) + ) { + finish(false, String(message['reason'])); + return; + } + } + closeEventPorts(event); + finish(false, 'TS render owner control refused'); + }; + const receiveRegistration = (event: unknown): void => { + if (settled || registrationFinished) { + closeEventPorts(event); + return; + } + registrationFinished = true; + stopHelper(); + if (registrationTimer !== undefined) creativeWindow.clearTimeout(registrationTimer); + const ports = eventPorts(event, 1); + let dataValue: unknown; + try { + dataValue = + typeof event === 'object' && event !== null ? Reflect.get(event, 'data') : undefined; + } catch { + dataValue = undefined; + } + const response = parseRegistration(dataValue); + if ( + !ports || + !response || + response['message'] !== 'TS Render Owner Registered' || + response['adId'] !== adId || + response['version'] !== 1 || + response['lifecycleTicket'] !== lifecycleTicket + ) { + closeEventPorts(event); + finish(false, 'TS render owner registration refused'); + return; + } + const registeredPort = ports[0]; + if (!registeredPort) { + finish(false, 'TS render owner registration refused'); + return; + } + controlPort = registeredPort; + ownerTimer = creativeWindow.setTimeout( + () => finish(false, 'TS render owner settlement timeout'), + 20_000 + ); + registeredPort.onmessage = receiveControl; + registeredPort.onmessageerror = () => finish(false, 'TS render owner channel failed'); + try { + registeredPort.start(); + } catch { + finish(false, 'TS render owner channel failed'); + } + }; + + const registrationTimer = creativeWindow.setTimeout( + () => finish(false, 'TS render owner registration timeout'), + 3_000 + ); + try { + const disposer = Reflect.apply(sendMessage, helper, [ + 'TS Render Owner Register', + { version: 1, lifecycleTicket }, + receiveRegistration, + ]) as unknown; + if (typeof disposer !== 'function') { + finish(false, 'TS render owner registration failed'); + return; + } + helperDisposer = disposer as () => void; + if (registrationFinished) stopHelper(); + } catch { + finish(false, 'TS render owner registration failed'); + } + }); +} + +/** Exact checked-in program returned through PUC's dynamic renderer field. */ +export const PUC_DYNAMIC_OWNER = `(${String(installPucDynamicOwner)})();`; + interface PendingClaim { readonly port: MessagingPort; readonly source: object; } +export interface PucRenderAttempt { + readonly id: string; + readonly slot: string; + readonly generation: object; + readonly navigationGeneration: object; + readonly renderSource: ReservationRenderSource | undefined; + readonly beginGamClaim: () => boolean; + readonly admitClaimedWinner: (claim: unknown) => boolean; + readonly ownerClaimed: () => boolean; + readonly ownerRegistered: () => boolean; + readonly beginApsDocument: (artifact: CommittedRenderArtifact) => boolean; + readonly beginAdm: (artifact: CommittedRenderArtifact) => boolean; + readonly apsDocumentAccepted: () => boolean; + readonly accept: () => boolean; + readonly cancel: (reason: 'caller_aborted' | 'superseded' | 'navigation_disposed') => boolean; + readonly fail: (reason: RenderFailureReason) => boolean; + readonly onSettled: (callback: (outcome: RenderOutcome) => void) => boolean; + readonly snapshot: () => Readonly<{ + state: string; + outcome: Readonly | undefined; + }>; +} + +export interface PucGamAttemptInput { + readonly attempt: PucRenderAttempt; + readonly artifact: CommittedRenderArtifact; + readonly owner: ReservationAttempt & + Readonly<{ generation: object; navigationGeneration: object }>; + readonly reservationId: string; +} + +export interface PucBridgeScheduler { + readonly set: (callback: () => void, milliseconds: number) => unknown; + readonly clear: (handle: unknown) => void; +} + export interface PucBridgeOptions { readonly messaging: MessagingAdapter; - readonly reservations: Pick; + readonly reservations: Pick & + Partial>; + readonly mintLifecycleTicket?: () => IdentityGenerationResult; + readonly now?: () => number; + readonly publisherOrigin?: string; + readonly rendererNonces?: Pick; + readonly rendererUrl?: string; + readonly resolveCacheAdm?: ( + attempt: PucRenderAttempt, + onResolved: (source: Readonly) => boolean + ) => boolean; + readonly scheduler?: PucBridgeScheduler; } export interface PucBridgeInventory { + readonly attempts: number; readonly disposed: boolean; + readonly liveTickets: number; readonly pendingClaims: number; + readonly ticketTombstones: number; } export interface PucBridge { + registerGamAttempt(input: PucGamAttemptInput): boolean; + recordNonemptyGam(input: PucGamAttemptInput): boolean; dispose(): void; snapshotInventoryForTest(): PucBridgeInventory; } +interface GamAttemptBinding { + readonly reservationId: string; + readonly attempt: PucRenderAttempt; + readonly artifact: CommittedRenderArtifact; + readonly owner: PucGamAttemptInput['owner']; + readonly attemptId: string; + readonly slot: string; + readonly navigationGeneration: object; + artifactOwned: boolean; + active: boolean; + claim: PendingClaim | undefined; + claimDeadlineHandle: unknown; + controlListenerDispose: (() => void) | undefined; + controlPort: MessagingPort | undefined; + controlStarted: boolean; + documentAccepted: boolean; + documentAcceptancePending: boolean; + documentTerminalPending: 'completed' | RenderFailureReason | undefined; + documentListenerDispose: (() => void) | undefined; + documentPort: MessagingPort | undefined; + documentPortRegistryOwned: boolean; + documentTransferredPort: MessagingPort | undefined; + gamReady: boolean; + joining: boolean; + lifecycleTicket: string | undefined; + nonce: string | undefined; + ownerInserted: boolean; + pucSource: object | undefined; + ticket: string | undefined; +} + +interface LiveTicket { + readonly state: 'live'; + readonly binding: GamAttemptBinding; + readonly expiresAt: number; + expiryHandle: unknown; +} + +interface PendingTicket { + readonly state: 'pending'; + readonly binding: GamAttemptBinding; +} + +interface TicketTombstone { + readonly state: 'tombstone'; + readonly expiresAt: number; + expiryHandle: unknown; +} + +type TicketEntry = LiveTicket | PendingTicket | TicketTombstone; + function mapValue(map: Map, key: Key): Value | undefined { return Reflect.apply(mapGetIntrinsic, map, [key]) as Value | undefined; } @@ -47,10 +773,26 @@ function setMapValue(map: Map, key: Key, value: Value): Reflect.apply(mapSetIntrinsic, map, [key, value]); } +function deleteMapValue(map: Map, key: Key): boolean { + return Reflect.apply(mapDeleteIntrinsic, map, [key]) as boolean; +} + function mapSize(map: Map): number { return Reflect.apply(mapSizeGetter, map, []) as number; } +function snapshotMapEntries(map: Map): readonly [Key, Value][] { + const iterator = Reflect.apply(mapEntriesIntrinsic, map, []) as IterableIterator<[Key, Value]>; + const entries: Array<[Key, Value]> = []; + while (true) { + const step = Reflect.apply(mapEntryIteratorNextIntrinsic, iterator, []) as IteratorResult< + [Key, Value] + >; + if (step.done) return entries; + entries[entries.length] = step.value; + } +} + function snapshotMapValues(map: Map): readonly Value[] { const iterator = Reflect.apply(mapValuesIntrinsic, map, []) as IterableIterator; const values: Value[] = []; @@ -65,6 +807,98 @@ function frozen(value: Value): Readonly { return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; } +function utf8Length(value: string): number { + return (Reflect.apply(textEncoderEncodeIntrinsic, textEncoder, [value]) as Uint8Array).byteLength; +} + +function defaultNow(): number { + return Date.now(); +} + +function defaultScheduler(): PucBridgeScheduler { + return frozen({ + set: (callback: () => void, milliseconds: number): unknown => + globalThis.setTimeout(callback, milliseconds), + clear: (handle: unknown): void => { + globalThis.clearTimeout(handle as ReturnType); + }, + }); +} + +function validTicket(value: unknown): value is string { + return typeof value === 'string' && LIFECYCLE_TICKET.test(value); +} + +function readMintedTicket(value: unknown): string | undefined { + try { + if (typeof value !== 'object' || value === null || !Object.isFrozen(value)) return undefined; + if (Object.getPrototypeOf(value) !== Object.prototype) return undefined; + if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const names = Object.getOwnPropertyNames(value).sort(); + const ok = Object.getOwnPropertyDescriptor(value, 'ok'); + if (!ok || !ok.enumerable || !('value' in ok)) return undefined; + if (ok.value === true && names.length === 2 && names[0] === 'ok' && names[1] === 'value') { + const ticket = Object.getOwnPropertyDescriptor(value, 'value'); + return ticket && ticket.enumerable && 'value' in ticket && validTicket(ticket.value) + ? ticket.value + : undefined; + } + return undefined; + } catch { + return undefined; + } +} + +function readRendererNonceIssue(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).sort(); + 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 && names[0] === 'nonce' && names[1] === 'ok') { + const nonce = Object.getOwnPropertyDescriptor(value, 'nonce'); + return nonce && + nonce.enumerable && + 'value' in nonce && + typeof nonce.value === 'string' && + /^n1_[A-Za-z0-9_-]{22}$/.test(nonce.value) + ? frozen({ ok: true as const, nonce: nonce.value }) + : undefined; + } + if (ok.value === false && names[0] === 'ok' && names[1] === 'reason') { + 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 frozen({ ok: false as const, reason: reason.value }); + } + } + return undefined; + } catch { + return undefined; + } +} + function recognizedReservation( reservations: Pick, reservationId: string @@ -140,6 +974,105 @@ function refuse(port: MessagingPort, adId: string): void { } } +function closePort(port: MessagingPort): void { + try { + port.close(); + } catch { + // The adapter facade contains raw close failures and is exact-once. + } +} + +function closeChannel( + channel: Readonly<{ retained: MessagingPort; transferred: MessagingPort }> +): void { + closePort(channel.retained); + closePort(channel.transferred); +} + +function readyResponse( + adId: string, + dynamicOwner: string, + kind: 'aps' | 'adm', + lifecycleTicket: string +): string | undefined { + try { + const owner = Object.create(null) as Record; + owner['version'] = 1; + owner['status'] = TSJS_MESSAGE_PROTOCOL_V1.status.ready; + owner['kind'] = kind; + owner['lifecycleTicket'] = lifecycleTicket; + const response = Object.create(null) as Record; + response['message'] = TSJS_MESSAGE_PROTOCOL_V1.message.prebidResponse; + response['adId'] = adId; + response['renderer'] = dynamicOwner; + response['rendererVersion'] = TSJS_MESSAGE_PROTOCOL_V1.rendererVersion; + response['tsOwner'] = owner; + const serialized = Reflect.apply(jsonStringifyIntrinsic, JSON, [response]) as unknown; + return typeof serialized === 'string' && utf8Length(serialized) <= MAX_OUTER_RESPONSE_BYTES + ? serialized + : undefined; + } catch { + return undefined; + } +} + +function ownerResponse(adId: string, lifecycleTicket: string | undefined): string | undefined { + try { + const response = Object.create(null) as Record; + response['message'] = + lifecycleTicket === undefined + ? TSJS_MESSAGE_PROTOCOL_V1.message.ownerRefused + : TSJS_MESSAGE_PROTOCOL_V1.message.ownerRegistered; + response['adId'] = adId; + response['version'] = 1; + if (lifecycleTicket !== undefined) response['lifecycleTicket'] = lifecycleTicket; + const serialized = Reflect.apply(jsonStringifyIntrinsic, JSON, [response]) as unknown; + return typeof serialized === 'string' ? serialized : undefined; + } catch { + return undefined; + } +} + +function ownerSettlement( + lifecycleTicket: string, + outcome: RenderOutcome +): Readonly> | undefined { + try { + const message = Object.create(null) as Record; + message['message'] = TSJS_MESSAGE_PROTOCOL_V1.message.ownerSettled; + message['version'] = 1; + message['lifecycleTicket'] = lifecycleTicket; + if (outcome.outcome === 'accepted') { + message['outcome'] = TSJS_MESSAGE_PROTOCOL_V1.outcome.accepted; + return frozen(message); + } + if (outcome.outcome === 'failed') { + message['outcome'] = TSJS_MESSAGE_PROTOCOL_V1.outcome.failed; + message['reason'] = outcome.reason; + return frozen(message); + } + if (outcome.outcome === 'cancelled') { + message['outcome'] = TSJS_MESSAGE_PROTOCOL_V1.outcome.cancelled; + message['reason'] = outcome.reason; + return frozen(message); + } + return undefined; + } catch { + return undefined; + } +} + +function refuseOwner(port: MessagingPort, adId: string): void { + try { + const response = ownerResponse(adId, undefined); + if (response !== undefined) port.post(response, []); + } catch { + // Refusal transport is best-effort; endpoint closure remains mandatory. + } finally { + closePort(port); + } +} + /** * Own the runtime-wide Universal Creative capture dispatcher. * @@ -147,19 +1080,1099 @@ function refuse(port: MessagingPort, adId: string): void { * malformed or replayed TS capabilities cannot fall through to native Prebid. */ export function createPucBridge(options: PucBridgeOptions): PucBridge { - const messaging = options.messaging; - const reservations = options.reservations; - const pendingClaims = new Map(); + let messaging: MessagingAdapter; + let reservations: PucBridgeOptions['reservations']; + let mintLifecycleTicket: () => IdentityGenerationResult; + let nowSource: () => number; + let publisherOrigin: string | undefined; + let rendererNonces: PucBridgeOptions['rendererNonces']; + let rendererUrl: string | undefined; + let resolveCacheAdm: PucBridgeOptions['resolveCacheAdm']; + let scheduler: PucBridgeScheduler; + try { + messaging = options.messaging; + reservations = options.reservations; + mintLifecycleTicket = options.mintLifecycleTicket ?? mintBrowserLifecycleTicket; + nowSource = options.now ?? defaultNow; + publisherOrigin = options.publisherOrigin; + rendererNonces = options.rendererNonces; + rendererUrl = options.rendererUrl; + resolveCacheAdm = options.resolveCacheAdm; + scheduler = options.scheduler ?? defaultScheduler(); + } catch { + messaging = options.messaging; + reservations = options.reservations; + mintLifecycleTicket = () => frozen({ ok: false, reason: 'identity_generation_failed' }); + nowSource = () => Number.NaN; + publisherOrigin = undefined; + rendererNonces = undefined; + rendererUrl = undefined; + resolveCacheAdm = undefined; + scheduler = defaultScheduler(); + } + let schedulerSet: PucBridgeScheduler['set']; + let schedulerClear: PucBridgeScheduler['clear']; + try { + schedulerSet = scheduler.set; + schedulerClear = scheduler.clear; + } catch { + schedulerSet = () => undefined; + schedulerClear = () => undefined; + } + const dynamicOwnerValid = utf8Length(PUC_DYNAMIC_OWNER) <= MAX_DYNAMIC_OWNER_BYTES; + const attempts = new Map(); + const tickets = new Map(); + let pendingTicketIssues = 0; + let lastNow = Number.NEGATIVE_INFINITY; let disposed = false; + const readNow = (): number | undefined => { + try { + const value = Reflect.apply(nowSource, undefined, []) as number; + if (!Number.isFinite(value) || value < 0 || value < lastNow) return undefined; + lastNow = value; + return value; + } catch { + return undefined; + } + }; + + const clearScheduled = (handle: unknown): void => { + if (handle === undefined) return; + try { + Reflect.apply(schedulerClear, scheduler, [handle]); + } catch { + // Timer cleanup is best-effort after state is already made inert. + } + }; + + const exactInput = (input: PucGamAttemptInput): PucGamAttemptInput | undefined => { + try { + const reservationId = input.reservationId; + const attempt = input.attempt; + const artifact = input.artifact; + const owner = input.owner; + if ( + typeof reservationId !== 'string' || + !RESERVATION_ID.test(reservationId) || + !ATTEMPT_ID.test(attempt.id) || + attempt.id !== owner.id || + attempt.slot !== owner.slot || + attempt.generation !== owner.generation || + attempt.navigationGeneration !== owner.navigationGeneration || + artifact.kind !== 'puc' || + artifact.attemptId !== attempt.id || + artifact.slot !== owner.slot || + artifact.navigationGeneration !== owner.navigationGeneration || + typeof artifact.dispose !== 'function' || + typeof attempt.beginGamClaim !== 'function' || + typeof attempt.admitClaimedWinner !== 'function' || + typeof attempt.ownerClaimed !== 'function' || + typeof attempt.ownerRegistered !== 'function' || + typeof attempt.beginApsDocument !== 'function' || + typeof attempt.beginAdm !== 'function' || + typeof attempt.apsDocumentAccepted !== 'function' || + typeof attempt.accept !== 'function' || + typeof attempt.cancel !== 'function' || + typeof attempt.fail !== 'function' || + typeof attempt.onSettled !== 'function' || + typeof attempt.snapshot !== 'function' || + typeof owner.isCurrent !== 'function' || + typeof owner.prepareWinnerContext !== 'function' + ) { + return undefined; + } + return frozen({ reservationId, attempt, artifact, owner }); + } catch { + return undefined; + } + }; + + const bindingMatches = (binding: GamAttemptBinding, input: PucGamAttemptInput): boolean => + binding.active && + binding.reservationId === input.reservationId && + binding.attempt === input.attempt && + binding.artifact === input.artifact && + binding.owner === input.owner && + binding.attemptId === input.attempt.id && + binding.slot === input.owner.slot && + binding.navigationGeneration === input.owner.navigationGeneration; + + const currentBindingState = (binding: GamAttemptBinding, expectedState: string): boolean => { + try { + const snapshot = Reflect.apply(binding.attempt.snapshot, binding.attempt, []); + return ( + binding.active && + mapValue(attempts, binding.reservationId) === binding && + binding.attempt.id === binding.attemptId && + binding.attempt.slot === binding.slot && + binding.attempt.navigationGeneration === binding.navigationGeneration && + binding.owner.id === binding.attemptId && + binding.owner.slot === binding.slot && + binding.owner.navigationGeneration === binding.navigationGeneration && + Reflect.apply(binding.owner.isCurrent, binding.owner, []) === true && + snapshot.outcome === undefined && + snapshot.state === expectedState + ); + } catch { + return false; + } + }; + + const tombstoneReservation = (binding: GamAttemptBinding): void => { + try { + const tombstone = reservations.tombstone; + if (typeof tombstone !== 'function') return; + Reflect.apply(tombstone, reservations, [ + frozen({ + reservationId: binding.reservationId, + slot: binding.slot, + navigationGeneration: binding.navigationGeneration, + attemptId: binding.attemptId, + }), + 'stale', + ]); + } catch { + // Attempt settlement stays authoritative if suppression publication fails. + } + }; + + const retireTicket = (binding: GamAttemptBinding): void => { + const ticket = binding.ticket; + if (!ticket) return; + const entry = mapValue(tickets, ticket); + if (entry?.state === 'live' && entry.binding === binding) { + clearScheduled(entry.expiryHandle); + const tombstone: TicketTombstone = { + state: 'tombstone', + expiresAt: entry.expiresAt, + expiryHandle: undefined, + }; + setMapValue(tickets, ticket, tombstone); + const retiredAt = readNow(); + if (retiredAt !== undefined && retiredAt >= tombstone.expiresAt) { + expireTicket(ticket, tombstone, retiredAt); + } else if (retiredAt !== undefined) { + let handle: unknown; + try { + handle = Reflect.apply(schedulerSet, scheduler, [ + () => expireTicket(ticket, tombstone), + tombstone.expiresAt - retiredAt, + ]); + } catch { + handle = undefined; + } + if (mapValue(tickets, ticket) !== tombstone) clearScheduled(handle); + else tombstone.expiryHandle = handle; + } + } else if (entry?.state === 'pending' && entry.binding === binding) { + const retiredAt = readNow(); + if (retiredAt === undefined) { + deleteMapValue(tickets, ticket); + } else { + const tombstone: TicketTombstone = { + state: 'tombstone', + expiresAt: retiredAt + LIFECYCLE_TICKET_TTL_MS, + expiryHandle: undefined, + }; + setMapValue(tickets, ticket, tombstone); + let handle: unknown; + try { + handle = Reflect.apply(schedulerSet, scheduler, [ + () => expireTicket(ticket, tombstone), + LIFECYCLE_TICKET_TTL_MS, + ]); + } catch { + handle = undefined; + } + if (mapValue(tickets, ticket) !== tombstone) { + clearScheduled(handle); + } else { + tombstone.expiryHandle = handle; + } + } + } + binding.ticket = undefined; + }; + + const clearClaimDeadline = (binding: GamAttemptBinding): void => { + const handle = binding.claimDeadlineHandle; + binding.claimDeadlineHandle = undefined; + clearScheduled(handle); + }; + + const disposeOwnedArtifact = (binding: GamAttemptBinding): void => { + if (!binding.artifactOwned) return; + binding.artifactOwned = false; + try { + Reflect.apply(binding.artifact.dispose, binding.artifact, []); + } catch { + // The bridge has already relinquished authority; disposal remains exact-once. + } + }; + + const cleanupBinding = (binding: GamAttemptBinding): void => { + if (!binding.active) return; + binding.active = false; + binding.joining = false; + disposeOwnedArtifact(binding); + clearClaimDeadline(binding); + if (mapValue(attempts, binding.reservationId) === binding) { + deleteMapValue(attempts, binding.reservationId); + } + const claim = binding.claim; + binding.claim = undefined; + if (claim) closePort(claim.port); + const disposeControlListener = binding.controlListenerDispose; + binding.controlListenerDispose = undefined; + if (disposeControlListener) { + try { + disposeControlListener(); + } catch { + // Listener disposal is best-effort after the binding is already inert. + } + } + const disposeDocumentListener = binding.documentListenerDispose; + binding.documentListenerDispose = undefined; + if (disposeDocumentListener) { + try { + disposeDocumentListener(); + } catch { + // Document listener disposal cannot interrupt endpoint cleanup. + } + } + const documentPort = binding.documentPort; + binding.documentPort = undefined; + if (documentPort && !binding.documentPortRegistryOwned) closePort(documentPort); + binding.documentPortRegistryOwned = false; + const documentTransferredPort = binding.documentTransferredPort; + binding.documentTransferredPort = undefined; + if (documentTransferredPort) closePort(documentTransferredPort); + const controlPort = binding.controlPort; + binding.controlPort = undefined; + if (controlPort) closePort(controlPort); + binding.lifecycleTicket = undefined; + binding.nonce = undefined; + binding.pucSource = undefined; + retireTicket(binding); + tombstoneReservation(binding); + }; + + const failBinding = ( + binding: GamAttemptBinding, + reason: RenderFailureReason, + refusePending: boolean + ): void => { + if (!binding.active) return; + if (binding.controlPort && binding.lifecycleTicket) { + try { + Reflect.apply(binding.attempt.fail, binding.attempt, [reason]); + } catch { + // The binding cleanup below remains authoritative. + } + if (binding.active) cleanupBinding(binding); + return; + } + const claim = binding.claim; + binding.claim = undefined; + if (claim) { + if (refusePending) refuse(claim.port, binding.reservationId); + else closePort(claim.port); + } + cleanupBinding(binding); + try { + Reflect.apply(binding.attempt.fail, binding.attempt, [reason]); + } catch { + // The binding is already inert and all owned endpoints are closed. + } + }; + + const settleBinding = (binding: GamAttemptBinding, outcome: RenderOutcome): void => { + if (!binding.active) return; + const controlPort = binding.controlPort; + const lifecycleTicket = binding.lifecycleTicket; + if (controlPort && lifecycleTicket) { + const settlement = ownerSettlement(lifecycleTicket, outcome); + if (settlement) { + try { + controlPort.post(settlement, []); + } catch { + // The remote owner's fixed watchdog contains settlement transport loss. + } + } + } + cleanupBinding(binding); + }; + + const expireTicket = ( + ticket: string, + expected: LiveTicket | TicketTombstone, + observedAt?: number + ): void => { + const entry = mapValue(tickets, ticket); + if (entry !== expected) return; + const now = observedAt ?? readNow(); + if (now === undefined || now < expected.expiresAt) return; + deleteMapValue(tickets, ticket); + if (entry.state === 'live') { + entry.binding.ticket = undefined; + if (entry.binding.active) { + failBinding(entry.binding, 'owner_registration_timeout', false); + } + } + }; + + const pruneExpiredTickets = (now: number): void => { + const entries = snapshotMapEntries(tickets); + for (let index = 0; index < entries.length; index += 1) { + const pair = entries[index]; + if ( + pair && + pair[1].state !== 'pending' && + pair[1].expiresAt <= now && + mapValue(tickets, pair[0]) === pair[1] + ) { + clearScheduled(pair[1].expiryHandle); + expireTicket(pair[0], pair[1], now); + } + } + }; + + const issueTicket = ( + binding: GamAttemptBinding + ): + | Readonly<{ ok: true; ticket: string }> + | Readonly<{ ok: false; reason: RenderFailureReason }> => { + const failure = ( + reason: RenderFailureReason + ): Readonly<{ ok: false; reason: RenderFailureReason }> => + frozen({ ok: false as const, reason }); + const pruneAt = readNow(); + if (pruneAt === undefined) return failure('identity_generation_failed'); + pruneExpiredTickets(pruneAt); + if (mapSize(tickets) + pendingTicketIssues >= MAX_TICKETS) { + return failure('capability_registry_full'); + } + pendingTicketIssues += 1; + try { + for (let draw = 0; draw < MAX_TICKET_DRAWS; draw += 1) { + let minted: unknown; + try { + minted = Reflect.apply(mintLifecycleTicket, undefined, []); + } catch { + return failure('identity_generation_failed'); + } + const ticket = readMintedTicket(minted); + if (!binding.active || disposed) { + return failure('internal_error'); + } + if (!ticket) return failure('identity_generation_failed'); + if (mapValue(tickets, ticket) !== undefined) continue; + if (mapSize(tickets) + pendingTicketIssues > MAX_TICKETS) { + return failure('capability_registry_full'); + } + const entry = frozen({ state: 'pending', binding }); + binding.ticket = ticket; + setMapValue(tickets, ticket, entry); + if (!binding.active || mapValue(tickets, ticket) !== entry || binding.ticket !== ticket) { + if (mapValue(tickets, ticket) === entry) deleteMapValue(tickets, ticket); + if (binding.ticket === ticket) binding.ticket = undefined; + return failure('internal_error'); + } + return frozen({ ok: true, ticket }); + } + return failure('identity_generation_failed'); + } finally { + pendingTicketIssues -= 1; + } + }; + + const activateTicket = (binding: GamAttemptBinding, ticket: string): boolean => { + const pending = mapValue(tickets, ticket); + const postedAt = readNow(); + if ( + pending?.state !== 'pending' || + pending.binding !== binding || + binding.ticket !== ticket || + postedAt === undefined + ) { + return false; + } + const expiresAt = postedAt + LIFECYCLE_TICKET_TTL_MS; + if (!Number.isFinite(expiresAt) || expiresAt <= postedAt) return false; + const live: LiveTicket = { + state: 'live', + binding, + expiresAt, + expiryHandle: undefined, + }; + setMapValue(tickets, ticket, live); + let expiryHandle: unknown; + try { + expiryHandle = Reflect.apply(schedulerSet, scheduler, [ + () => expireTicket(ticket, live), + LIFECYCLE_TICKET_TTL_MS, + ]); + } catch { + expiryHandle = undefined; + } + if ( + expiryHandle === undefined || + !binding.active || + mapValue(tickets, ticket) !== live || + binding.ticket !== ticket + ) { + clearScheduled(expiryHandle); + if (binding.active && binding.ticket === ticket && mapValue(tickets, ticket) === live) { + setMapValue(tickets, ticket, pending); + } else { + if (mapValue(tickets, ticket) === live) deleteMapValue(tickets, ticket); + if (binding.ticket === ticket) binding.ticket = undefined; + } + return false; + } + live.expiryHandle = expiryHandle; + return true; + }; + + const join = (binding: GamAttemptBinding): boolean => { + if ( + !binding.active || + !binding.gamReady || + !binding.claim || + binding.joining || + !currentBindingState(binding, 'waiting_for_gam_and_claim') + ) { + return false; + } + binding.joining = true; + clearClaimDeadline(binding); + const pending = binding.claim; + try { + let claimed: unknown; + try { + claimed = Reflect.apply(reservations.claim, reservations, [ + frozen({ + reservationId: binding.reservationId, + slot: binding.slot, + navigationGeneration: binding.navigationGeneration, + attempt: binding.owner, + pucSource: pending.source, + }), + ]); + } catch { + claimed = undefined; + } + let claimedSuccessfully = false; + try { + claimedSuccessfully = + typeof claimed === 'object' && + claimed !== null && + (claimed as { recognized?: unknown }).recognized === true && + (claimed as { claimed?: unknown }).claimed === true; + } catch { + claimedSuccessfully = false; + } + if ( + !claimedSuccessfully || + Reflect.apply(binding.attempt.admitClaimedWinner, binding.attempt, [claimed]) !== true || + Reflect.apply(binding.attempt.ownerClaimed, binding.attempt, []) !== true || + !currentBindingState(binding, 'waiting_for_owner') + ) { + failBinding(binding, 'bridge_id_mismatch', true); + return false; + } + binding.pucSource = pending.source; + let kind: 'aps' | 'adm'; + try { + const sourceType = binding.attempt.renderSource?.type; + if (sourceType === 'aps') kind = 'aps'; + else if (sourceType === 'adm' || sourceType === 'cache') kind = 'adm'; + else throw new Error('claimed source is unavailable'); + } catch { + failBinding(binding, 'bridge_id_mismatch', true); + return false; + } + const issued = issueTicket(binding); + if (!issued.ok) { + failBinding(binding, issued.reason, true); + return false; + } + const response = readyResponse(binding.reservationId, PUC_DYNAMIC_OWNER, kind, issued.ticket); + if (!response) { + failBinding(binding, 'internal_error', true); + return false; + } + binding.claim = undefined; + let posted = false; + try { + posted = pending.port.post(response, []) === true; + } catch { + posted = false; + } + closePort(pending.port); + if (!posted || !binding.active || !activateTicket(binding, issued.ticket)) { + if (binding.active) failBinding(binding, 'internal_error', false); + return false; + } + return true; + } finally { + if (binding.active) binding.joining = false; + } + }; + + const armClaimDeadline = (binding: GamAttemptBinding): boolean => { + if (!binding.active || binding.claimDeadlineHandle !== undefined) return false; + let handle: unknown; + try { + handle = Reflect.apply(schedulerSet, scheduler, [ + () => { + if ( + binding.active && + binding.gamReady && + !binding.claim && + mapValue(attempts, binding.reservationId) === binding + ) { + binding.claimDeadlineHandle = undefined; + failBinding(binding, 'bridge_claim_timeout', false); + } + }, + CLAIM_DEADLINE_MS, + ]); + } catch { + handle = undefined; + } + if (handle === undefined || !binding.active) { + clearScheduled(handle); + if (binding.active) failBinding(binding, 'internal_error', false); + return false; + } + binding.claimDeadlineHandle = handle; + return true; + }; + + const startAdmOwner = ( + binding: GamAttemptBinding, + lifecycleTicket: string, + resolvedSource?: Readonly + ): boolean => { + const controlPort = binding.controlPort; + if (!controlPort || binding.controlStarted || !binding.active) return false; + let source: unknown; + try { + source = resolvedSource ?? binding.attempt.renderSource; + } catch { + source = undefined; + } + const start = messaging.parseProtocolMessage('admStart', { + message: TSJS_MESSAGE_PROTOCOL_V1.message.admStart, + version: 1, + lifecycleTicket, + source, + }); + if (!start) { + failBinding(binding, 'winner_not_renderable', false); + return false; + } + const receive = (event: unknown): void => { + if (!binding.active || binding.controlPort !== controlPort || !binding.controlStarted) return; + if (!messaging.extractTransferredPorts(event, 0)) { + failBinding(binding, 'internal_error', false); + return; + } + const data = eventData(event); + const inserted = messaging.parseProtocolMessage('ownerInserted', data); + if (inserted?.['lifecycleTicket'] === lifecycleTicket) { + if (binding.ownerInserted) return; + binding.artifactOwned = false; + const began = (() => { + try { + return ( + Reflect.apply(binding.attempt.beginAdm, binding.attempt, [binding.artifact]) === true + ); + } catch { + return false; + } + })(); + if (!began) { + if (binding.active) binding.artifactOwned = true; + failBinding(binding, 'internal_error', false); + return; + } + binding.ownerInserted = true; + return; + } + const loaded = messaging.parseProtocolMessage('admLoaded', data); + if (loaded?.['lifecycleTicket'] === lifecycleTicket) { + if (!binding.ownerInserted) { + failBinding(binding, 'adm_document_no_load', false); + return; + } + const accepted = (() => { + try { + return Reflect.apply(binding.attempt.accept, binding.attempt, []) === true; + } catch { + return false; + } + })(); + if (!accepted && binding.active) failBinding(binding, 'internal_error', false); + return; + } + const failed = messaging.parseProtocolMessage('admFailed', data); + if (failed?.['lifecycleTicket'] === lifecycleTicket) { + failBinding(binding, 'adm_document_no_load', false); + return; + } + failBinding(binding, 'internal_error', false); + }; + const receiveError = (): void => failBinding(binding, 'adm_document_no_load', false); + try { + binding.controlListenerDispose = controlPort.listen(receive, receiveError); + binding.controlStarted = true; + if (controlPort.post(start, []) !== true) { + failBinding(binding, 'internal_error', false); + return false; + } + return binding.active; + } catch { + failBinding(binding, 'internal_error', false); + return false; + } + }; + + const resolveCacheOwner = (binding: GamAttemptBinding, lifecycleTicket: string): boolean => { + if (!binding.active || binding.controlStarted || typeof resolveCacheAdm !== 'function') { + failBinding(binding, 'winner_not_renderable', false); + return false; + } + const resolutionStarted = (() => { + try { + return ( + Reflect.apply(resolveCacheAdm, undefined, [ + binding.attempt, + (source: Readonly): boolean => { + if ( + !binding.active || + binding.controlStarted || + binding.attempt.renderSource?.type !== 'cache' || + !currentBindingState(binding, 'waiting_for_insertion') + ) { + return false; + } + return startAdmOwner(binding, lifecycleTicket, source); + }, + ]) === true + ); + } catch { + return false; + } + })(); + if (!resolutionStarted && binding.active) { + failBinding(binding, 'internal_error', false); + return false; + } + return resolutionStarted; + }; + + const startApsOwner = (binding: GamAttemptBinding, lifecycleTicket: string): boolean => { + const controlPort = binding.controlPort; + const pucSource = binding.pucSource; + if ( + !controlPort || + !pucSource || + binding.controlStarted || + !binding.active || + !rendererNonces || + typeof publisherOrigin !== 'string' || + typeof rendererUrl !== 'string' + ) { + failBinding(binding, 'internal_error', false); + return false; + } + const documentChannel = messaging.createChannel(); + if (!documentChannel) { + failBinding(binding, 'internal_error', false); + return false; + } + if ( + !binding.active || + binding.controlPort !== controlPort || + !currentBindingState(binding, 'waiting_for_insertion') + ) { + closeChannel(documentChannel); + return false; + } + binding.documentPort = documentChannel.retained; + binding.documentTransferredPort = documentChannel.transferred; + binding.documentPortRegistryOwned = true; + let issuedValue: unknown; + try { + issuedValue = Reflect.apply(rendererNonces.issue, rendererNonces, [ + frozen({ + attempt: binding.attempt as unknown as RenderAttempt, + source: pucSource, + port: documentChannel.retained, + }), + ]); + } catch { + issuedValue = undefined; + } + const issued = readRendererNonceIssue(issuedValue); + if (!issued?.ok) { + binding.documentPortRegistryOwned = false; + if (!binding.active) { + closePort(documentChannel.retained); + closePort(documentChannel.transferred); + return false; + } + const reason = issued?.reason; + failBinding( + binding, + reason === 'capability_registry_full' || reason === 'identity_generation_failed' + ? reason + : 'internal_error', + false + ); + return false; + } + if (!binding.active) return false; + const nonce = issued.nonce; + binding.nonce = nonce; + let source: unknown; + try { + source = binding.attempt.renderSource; + } catch { + source = undefined; + } + const start = messaging.parseProtocolMessage('apsStart', { + message: TSJS_MESSAGE_PROTOCOL_V1.message.apsStart, + version: 1, + lifecycleTicket, + rendererUrl, + envelope: { + version: 1, + nonce, + publisherOrigin, + renderer: source, + }, + }); + if (!start) { + failBinding(binding, 'winner_not_renderable', false); + return false; + } + const nonceExpectation = () => + frozen({ + nonce, + attempt: binding.attempt as unknown as RenderAttempt, + generation: binding.attempt.generation, + source: pucSource, + port: documentChannel.retained, + }); + const acceptDocument = (): boolean => { + if (!binding.active || binding.documentAccepted || !binding.ownerInserted) return false; + const advanced = (() => { + try { + return ( + Reflect.apply(rendererNonces.consume, rendererNonces, [nonceExpectation()]) === true && + Reflect.apply(binding.attempt.apsDocumentAccepted, binding.attempt, []) === true + ); + } catch { + return false; + } + })(); + if (!advanced) { + failBinding(binding, 'renderer_document_no_load', false); + return false; + } + binding.documentAcceptancePending = false; + binding.documentAccepted = true; + return true; + }; + const receiveDocument = (event: unknown): void => { + if (!binding.active || binding.documentPort !== documentChannel.retained) return; + if (!messaging.extractTransferredPorts(event, 0)) { + failBinding( + binding, + binding.documentAccepted ? 'runner_failed' : 'renderer_document_no_load', + false + ); + return; + } + const data = eventData(event); + const accepted = messaging.parseProtocolMessage('apsDocumentAccepted', data); + if (accepted?.['nonce'] === nonce) { + if (binding.documentAccepted) return; + if (!binding.ownerInserted) { + binding.documentAcceptancePending = true; + return; + } + acceptDocument(); + return; + } + const loaded = messaging.parseProtocolMessage('apsRunnerLoaded', data); + if (loaded?.['nonce'] === nonce) { + if (!binding.documentAccepted && !binding.documentAcceptancePending) { + failBinding(binding, 'renderer_document_no_load', false); + } + return; + } + const completed = messaging.parseProtocolMessage('apsRenderCompleted', data); + if (completed?.['nonce'] === nonce) { + if (!binding.documentAccepted) { + if (binding.documentAcceptancePending) { + binding.documentTerminalPending = 'completed'; + return; + } + failBinding(binding, 'renderer_document_no_load', false); + return; + } + const rendered = (() => { + try { + return Reflect.apply(binding.attempt.accept, binding.attempt, []) === true; + } catch { + return false; + } + })(); + if (!rendered && binding.active) failBinding(binding, 'internal_error', false); + return; + } + const failed = messaging.parseProtocolMessage('apsRenderFailed', data); + if (failed?.['nonce'] === nonce) { + const reason = failed['reason']; + const mapped = + reason === 'descriptor_invalid' || + reason === 'runner_no_load' || + reason === 'runner_failed' + ? reason + : 'winner_not_renderable'; + if (!binding.documentAccepted && binding.documentAcceptancePending) { + binding.documentTerminalPending = mapped; + return; + } + failBinding(binding, mapped, false); + return; + } + failBinding( + binding, + binding.documentAccepted ? 'runner_failed' : 'renderer_document_no_load', + false + ); + }; + const receiveDocumentError = (): void => + failBinding( + binding, + binding.documentAccepted ? 'runner_failed' : 'renderer_document_no_load', + false + ); + const receiveControl = (event: unknown): void => { + if (!binding.active || binding.controlPort !== controlPort || !binding.controlStarted) return; + if (!messaging.extractTransferredPorts(event, 0)) { + failBinding(binding, 'internal_error', false); + return; + } + const inserted = messaging.parseProtocolMessage('ownerInserted', eventData(event)); + if (inserted?.['lifecycleTicket'] !== lifecycleTicket) { + failBinding(binding, 'internal_error', false); + return; + } + if (binding.ownerInserted) return; + binding.artifactOwned = false; + const began = (() => { + try { + return ( + Reflect.apply(binding.attempt.beginApsDocument, binding.attempt, [binding.artifact]) === + true + ); + } catch { + return false; + } + })(); + if (!began) { + if (binding.active) binding.artifactOwned = true; + failBinding(binding, 'internal_error', false); + return; + } + binding.ownerInserted = true; + if (binding.documentAcceptancePending) { + acceptDocument(); + if (!binding.active) return; + const terminal = binding.documentTerminalPending; + binding.documentTerminalPending = undefined; + if (terminal === 'completed') { + const rendered = (() => { + try { + return Reflect.apply(binding.attempt.accept, binding.attempt, []) === true; + } catch { + return false; + } + })(); + if (!rendered && binding.active) failBinding(binding, 'internal_error', false); + } else if (terminal) { + failBinding(binding, terminal, false); + } + } + }; + const receiveControlError = (): void => failBinding(binding, 'internal_error', false); + try { + binding.documentListenerDispose = documentChannel.retained.listen( + receiveDocument, + receiveDocumentError + ); + binding.controlListenerDispose = controlPort.listen(receiveControl, receiveControlError); + binding.controlStarted = true; + if (controlPort.post(start, [documentChannel.transferred]) !== true) { + failBinding(binding, 'internal_error', false); + return false; + } + closePort(documentChannel.transferred); + return binding.active; + } catch { + failBinding(binding, 'internal_error', false); + return false; + } + }; + + const handleOwnerRegistration = ( + event: MessageEvent, + data: unknown, + routing: Readonly<{ message: string; adId?: string; lifecycleTicket?: string }> + ): void => { + const ticket = routing.lifecycleTicket; + if (!ticket) return; + const now = readNow(); + if (now === undefined) return; + pruneExpiredTickets(now); + const entry = mapValue(tickets, ticket); + if (!entry) return; + if (!suppress(event)) return; + + const exact = messaging.parseProtocolMessage('ownerRegister', data); + const inspection = messaging.inspectTransferredPorts(event); + const ports = inspection?.ports; + const responsePort = ports?.[0]; + const exactPort = + inspection?.exactShape === true && inspection.originalCount === 1 && ports?.length === 1; + const closeAdditionalPorts = (): void => { + if (!ports) return; + for (let index = 1; index < ports.length; index += 1) { + const port = ports[index]; + if (port) closePort(port); + } + }; + if (entry.state === 'tombstone') { + if (responsePort) refuseOwner(responsePort, routing.adId ?? ''); + closeAdditionalPorts(); + return; + } + + if (entry.state === 'pending') { + if (responsePort) refuseOwner(responsePort, routing.adId ?? ''); + closeAdditionalPorts(); + retireTicket(entry.binding); + failBinding(entry.binding, 'bridge_id_mismatch', false); + return; + } + + const binding = entry.binding; + const invalidate = (): void => { + if (responsePort) refuseOwner(responsePort, routing.adId ?? binding.reservationId); + closeAdditionalPorts(); + retireTicket(binding); + failBinding(binding, 'bridge_id_mismatch', false); + }; + if (!exact || !responsePort || !exactPort) { + invalidate(); + return; + } + const source = eventSource(event); + let exactAdId: unknown; + let exactTicket: unknown; + try { + exactAdId = exact['adId']; + exactTicket = exact['lifecycleTicket']; + } catch { + invalidate(); + return; + } + if ( + source === undefined || + source !== binding.pucSource || + exactAdId !== binding.reservationId || + exactTicket !== ticket || + binding.ticket !== ticket || + mapValue(tickets, ticket) !== entry || + !currentBindingState(binding, 'waiting_for_owner') + ) { + invalidate(); + return; + } + + binding.lifecycleTicket = ticket; + retireTicket(binding); + const channel = messaging.createChannel(); + if (!channel) { + refuseOwner(responsePort, binding.reservationId); + failBinding(binding, 'internal_error', false); + return; + } + if (!binding.active || !currentBindingState(binding, 'waiting_for_owner')) { + closeChannel(channel); + refuseOwner(responsePort, binding.reservationId); + if (binding.active) failBinding(binding, 'internal_error', false); + return; + } + binding.controlPort = channel.retained; + const registered = (() => { + try { + return ( + Reflect.apply(binding.attempt.ownerRegistered, binding.attempt, []) === true && + currentBindingState(binding, 'waiting_for_insertion') + ); + } catch { + return false; + } + })(); + const response = registered ? ownerResponse(binding.reservationId, ticket) : undefined; + let posted = false; + if (response) { + try { + posted = responsePort.post(response, [channel.transferred]) === true; + } catch { + posted = false; + } + } + closePort(responsePort); + closePort(channel.transferred); + if (!registered || !posted || !binding.active) { + if (binding.active) failBinding(binding, 'internal_error', false); + return; + } + let sourceType: unknown; + try { + sourceType = binding.attempt.renderSource?.type; + } catch { + sourceType = undefined; + } + if (sourceType === 'adm') { + startAdmOwner(binding, ticket); + } else if (sourceType === 'cache') { + resolveCacheOwner(binding, ticket); + } else if (sourceType === 'aps') { + startApsOwner(binding, ticket); + } else { + failBinding(binding, 'winner_not_renderable', false); + } + }; + const dispatch = (event: MessageEvent): void => { if (disposed) return; const data = eventData(event); const routing = messaging.inspectGlobalMessage(data); - if ( - routing?.message !== TSJS_MESSAGE_PROTOCOL_V1.message.prebidRequest || - routing.adId === undefined - ) { + if (routing?.message === TSJS_MESSAGE_PROTOCOL_V1.message.ownerRegister) { + handleOwnerRegistration(event, data, routing); + return; + } + if (routing?.message !== TSJS_MESSAGE_PROTOCOL_V1.message.prebidRequest || !routing.adId) { return; } @@ -168,26 +2181,158 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { if (!suppress(event)) return; const exact = messaging.parseProtocolMessage('prebidRequest', data); - const ports = messaging.extractTransferredPorts(event, 1); + const inspection = messaging.inspectTransferredPorts(event); + const ports = inspection?.ports; const port = ports?.[0]; - if (!port) return; - if (exact === undefined || recognition.state !== 'renderable') { + if (!inspection || !port) return; + if ( + inspection.exactShape !== true || + inspection.originalCount !== 1 || + ports.length !== 1 || + exact === undefined || + recognition.state !== 'renderable' + ) { refuse(port, routing.adId); + for (let index = 1; index < ports.length; index += 1) { + const extra = ports[index]; + if (extra) closePort(extra); + } return; } const source = eventSource(event); - if (source === undefined || mapValue(pendingClaims, routing.adId) !== undefined) { + const binding = mapValue(attempts, routing.adId); + if ( + source === undefined || + !binding?.active || + binding.claim !== undefined || + !currentBindingState(binding, 'waiting_for_gam_and_claim') + ) { refuse(port, routing.adId); return; } - setMapValue(pendingClaims, routing.adId, frozen({ port, source })); + binding.claim = frozen({ port, source }); + binding.pucSource = source; + if (binding.gamReady) join(binding); }; const uninstall = messaging.installCaptureListener(dispatch); const bridge: PucBridge = { + registerGamAttempt(input): boolean { + if (disposed || !dynamicOwnerValid) return false; + const exact = exactInput(input); + if (!exact || mapValue(attempts, exact.reservationId) !== undefined) return false; + const existing = snapshotMapValues(attempts); + for (let index = 0; index < existing.length; index += 1) { + const candidate = existing[index]; + if ( + candidate && + (candidate.attempt === exact.attempt || + candidate.attempt.generation === exact.attempt.generation) + ) { + return false; + } + } + const recognition = recognizedReservation(reservations, exact.reservationId); + if (recognition?.recognized !== true || recognition.state !== 'renderable') return false; + try { + const snapshot = Reflect.apply(exact.attempt.snapshot, exact.attempt, []); + if ( + Reflect.apply(exact.owner.isCurrent, exact.owner, []) !== true || + snapshot.outcome !== undefined || + snapshot.state !== 'created' || + exact.attempt.renderSource !== undefined + ) { + return false; + } + } catch { + return false; + } + const started = (() => { + try { + return Reflect.apply(exact.attempt.beginGamClaim, exact.attempt, []) === true; + } catch { + return false; + } + })(); + if (!started) return false; + const binding: GamAttemptBinding = { + reservationId: exact.reservationId, + attempt: exact.attempt, + artifact: exact.artifact, + owner: exact.owner, + attemptId: exact.attempt.id, + slot: exact.owner.slot, + navigationGeneration: exact.owner.navigationGeneration, + artifactOwned: true, + active: true, + claim: undefined, + claimDeadlineHandle: undefined, + controlListenerDispose: undefined, + controlPort: undefined, + controlStarted: false, + documentAccepted: false, + documentAcceptancePending: false, + documentTerminalPending: undefined, + documentListenerDispose: undefined, + documentPort: undefined, + documentPortRegistryOwned: false, + documentTransferredPort: undefined, + gamReady: false, + joining: false, + lifecycleTicket: undefined, + nonce: undefined, + ownerInserted: false, + pucSource: undefined, + ticket: undefined, + }; + setMapValue(attempts, exact.reservationId, binding); + if (!currentBindingState(binding, 'waiting_for_gam_and_claim')) { + cleanupBinding(binding); + return false; + } + const observed = (() => { + try { + return ( + Reflect.apply(exact.attempt.onSettled, exact.attempt, [ + (outcome: RenderOutcome) => settleBinding(binding, outcome), + ]) === true + ); + } catch { + return false; + } + })(); + if (!observed || !binding.active || mapValue(attempts, exact.reservationId) !== binding) { + cleanupBinding(binding); + try { + Reflect.apply(exact.attempt.fail, exact.attempt, ['internal_error']); + } catch { + // Registration rejection already retains no bridge authority. + } + return false; + } + return true; + }, + recordNonemptyGam(input): boolean { + if (disposed) return false; + const exact = exactInput(input); + if (!exact) return false; + const binding = mapValue(attempts, exact.reservationId); + if ( + !binding || + !bindingMatches(binding, exact) || + binding.gamReady || + !currentBindingState(binding, 'waiting_for_gam_and_claim') + ) { + return false; + } + binding.gamReady = true; + if (binding.claim) join(binding); + else armClaimDeadline(binding); + return true; + }, dispose(): void { if (disposed) return; disposed = true; @@ -196,20 +2341,51 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { } catch { // Listener removal is already contained by the adapter. } - const claims = snapshotMapValues(pendingClaims); - for (let index = 0; index < claims.length; index += 1) { - const claim = claims[index]; - if (!claim) continue; - try { - claim.port.close(); - } catch { - // Endpoint cleanup is exact-once at the adapter facade. - } + const bindings = snapshotMapValues(attempts); + for (let index = 0; index < bindings.length; index += 1) { + const binding = bindings[index]; + if (!binding) continue; + const cancelled = (() => { + try { + return ( + Reflect.apply(binding.attempt.cancel, binding.attempt, ['navigation_disposed']) === + true + ); + } catch { + return false; + } + })(); + if (!cancelled && binding.active) cleanupBinding(binding); } - Reflect.apply(mapClearIntrinsic, pendingClaims, []); + const ticketEntries = snapshotMapValues(tickets); + for (let index = 0; index < ticketEntries.length; index += 1) { + const entry = ticketEntries[index]; + if (entry?.state !== 'pending') clearScheduled(entry?.expiryHandle); + } + Reflect.apply(mapClearIntrinsic, attempts, []); + Reflect.apply(mapClearIntrinsic, tickets, []); }, snapshotInventoryForTest(): PucBridgeInventory { - return frozen({ disposed, pendingClaims: mapSize(pendingClaims) }); + let pendingClaims = 0; + const bindings = snapshotMapValues(attempts); + for (let index = 0; index < bindings.length; index += 1) { + if (bindings[index]?.claim) pendingClaims += 1; + } + let liveTickets = 0; + let ticketTombstones = 0; + const ticketEntries = snapshotMapValues(tickets); + for (let index = 0; index < ticketEntries.length; index += 1) { + if (ticketEntries[index]?.state === 'live' || ticketEntries[index]?.state === 'pending') { + liveTickets += 1; + } else if (ticketEntries[index]?.state === 'tombstone') ticketTombstones += 1; + } + return frozen({ + attempts: mapSize(attempts), + disposed, + liveTickets, + pendingClaims, + ticketTombstones, + }); }, }; return frozen(bridge); diff --git a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts index 3d4c02474..a2701f620 100644 --- a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts @@ -1214,6 +1214,32 @@ describe('browser messaging adapter', () => { 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(); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 485109a14..b55c64705 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -185,14 +185,12 @@ describe('browser composition', () => { adapters: { googletag: fakeGoogletagAdapter(), prebid: fakePrebidAdapter(), - messaging: fakeMessagingAdapter(), + messaging: fakeMessagingAdapter(() => { + order.push('bridge'); + return () => order.push('dispose-bridge'); + }), }, coreActivations: { - bridgeRecognizer: ({ onDispose }, adapters) => { - expect(Object.isFrozen(adapters)).toBe(true); - onDispose(() => order.push('dispose-bridge')); - order.push('bridge'); - }, correctnessGptListeners: ({ onDispose }, adapters) => { expect(Object.isFrozen(adapters)).toBe(true); onDispose(() => order.push('dispose-gpt')); @@ -216,6 +214,7 @@ describe('browser composition', () => { ).toBe(true); await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); expect(order).toEqual(['bridge', 'gpt', 'module']); + expect(composition.pucBridgeForTest()).toBeDefined(); composition.runtime.dispose(); expect(order).toEqual([ @@ -226,6 +225,7 @@ describe('browser composition', () => { 'dispose-gpt', 'dispose-bridge', ]); + expect(composition.pucBridgeForTest()).toBeUndefined(); expect(() => composition.adapters.googletag.run(() => undefined)).toThrowError( expect.objectContaining({ code: 'operation_disposed' }) ); @@ -289,9 +289,6 @@ describe('browser composition', () => { prebid: fakePrebidAdapter(), }, coreActivations: { - bridgeRecognizer: vi.fn(() => { - expect(subscriptions).toEqual([]); - }), correctnessGptListeners: correctness, }, } @@ -339,7 +336,6 @@ describe('browser composition', () => { messaging: fakeMessagingAdapter(), }, coreActivations: { - bridgeRecognizer: vi.fn(), correctnessGptListeners: vi.fn(), }, createIdentityIssuerForTest: () => { @@ -429,7 +425,6 @@ describe('browser composition', () => { }); it('unwinds a lazily-created session when navigation identity generation fails', async () => { - const bridge = vi.fn(); const composition = createTestBrowserRuntimeComposition( { target: {}, @@ -449,7 +444,6 @@ describe('browser composition', () => { }, { coreActivations: { - bridgeRecognizer: bridge, correctnessGptListeners: vi.fn(), }, createIdentityIssuerForTest: () => ({ @@ -466,7 +460,7 @@ describe('browser composition', () => { }); expect(composition.runtimeSessionForTest()).toBeUndefined(); expect(composition.projectionSlotsForTest()).toBeUndefined(); - expect(bridge).not.toHaveBeenCalled(); + expect(composition.pucBridgeForTest()).toBeUndefined(); }); it('releases initial programmatic slots before admitting a replacement SPA projection', async () => { @@ -494,7 +488,6 @@ describe('browser composition', () => { { admittedProgrammaticSlotsForTest: programmaticSlots, coreActivations: { - bridgeRecognizer: vi.fn(), correctnessGptListeners: vi.fn(), }, createIdentityIssuerForTest: () => { @@ -551,7 +544,6 @@ describe('browser composition', () => { { admittedProgrammaticSlotsForTest: Object.freeze(['duplicate', 'duplicate']), coreActivations: { - bridgeRecognizer: vi.fn(), correctnessGptListeners: vi.fn(), }, } @@ -601,7 +593,6 @@ describe('browser composition', () => { Array.from({ length: programmaticCount }, (_, index) => `programmatic-${index}`) ), coreActivations: { - bridgeRecognizer: vi.fn(), correctnessGptListeners: vi.fn(), }, } @@ -640,7 +631,6 @@ describe('browser composition', () => { { admittedProgrammaticSlotsForTest: programmaticSlots, coreActivations: { - bridgeRecognizer: vi.fn(), correctnessGptListeners: vi.fn(), }, } @@ -690,7 +680,6 @@ describe('browser composition', () => { prebid: fakePrebidAdapter(), }, coreActivations: { - bridgeRecognizer: vi.fn(), correctnessGptListeners: vi.fn(), }, } @@ -723,7 +712,6 @@ describe('browser composition', () => { })); const adapterActivation = vi.fn(() => 'pending' as const); const listenerActivation = vi.fn(() => vi.fn()); - const timerActivation = vi.fn(() => setTimeout(vi.fn(), 1)); const latePreparation = vi.fn(); const target = {}; const composition = createTestBrowserRuntimeComposition( @@ -759,7 +747,6 @@ describe('browser composition', () => { messaging: fakeMessagingAdapter(listenerActivation), }, coreActivations: { - bridgeRecognizer: timerActivation, correctnessGptListeners: adapterActivation, }, } @@ -786,7 +773,6 @@ describe('browser composition', () => { expect(serviceConstruction).not.toHaveBeenCalled(); expect(adapterActivation).not.toHaveBeenCalled(); expect(listenerActivation).not.toHaveBeenCalled(); - expect(timerActivation).not.toHaveBeenCalled(); expect(latePreparation).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }); 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 index ea171ba85..d4002def2 100644 --- a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -1,10 +1,21 @@ import { describe, expect, it, vi } from 'vitest'; import { createBrowserMessagingAdapter } from '../../src/adapters/messaging'; -import { createPucBridge } from '../../src/services/puc_bridge'; -import type { ReservationRecognition } from '../../src/services/reservations'; +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 { @@ -24,7 +35,31 @@ function exactRequest(adId = RESERVATION_ID): string { }); } -function createHarness(recognize: (reservationId: unknown) => ReservationRecognition) { +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 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( @@ -33,11 +68,27 @@ function createHarness(recognize: (reservationId: unknown) => ReservationRecogni } ), removeEventListener: vi.fn(), + ...(options.messageChannel ? { MessageChannel: options.messageChannel } : {}), }; - const bridge = createPucBridge({ - messaging: createBrowserMessagingAdapter(target), - reservations: { recognize }, - }); + 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.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); @@ -45,7 +96,904 @@ function createHarness(recognize: (reservationId: unknown) => ReservationRecogni 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 =')); + expect(admOwner.indexOf('next.onerror =')).toBeLessThan(admOwner.indexOf('next.srcdoc =')); + expect(apsOwner.indexOf('next.onload =')).toBeLessThan(apsOwner.indexOf('next.src =')); + expect(apsOwner.indexOf('next.onerror =')).toBeLessThan(apsOwner.indexOf('next.src =')); + }); + + 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?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + expect(stopListening).toHaveBeenCalledOnce(); + expect(controlPort.start).toHaveBeenCalledOnce(); + + controlListener?.({ + 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?.({ + 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('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('refuses an APS owner start whose renderer URL is outside the publisher origin', 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://attacker.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 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 })); @@ -53,8 +1001,11 @@ describe('Universal Creative bridge dispatcher', () => { 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(); @@ -63,8 +1014,11 @@ describe('Universal Creative bridge dispatcher', () => { 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, }); }); @@ -130,7 +1084,7 @@ describe('Universal Creative bridge dispatcher', () => { expect(port.close).toHaveBeenCalledOnce(); }); - it('suppresses recognized requests with the wrong port count and closes every available port', () => { + it('suppresses recognized requests with the wrong port count, refuses on the first, and closes every port', () => { const harness = createHarness(() => ({ recognized: true, state: 'renderable', @@ -138,24 +1092,46 @@ describe('Universal Creative bridge dispatcher', () => { })); const first = createPort(); const second = createPort(); + const third = createPort(); const stopImmediatePropagation = vi.fn(); harness.dispatch({ data: exactRequest(), - ports: [first, second], + ports: [first, second, third], source: Object.freeze({}), stopImmediatePropagation, }); expect(stopImmediatePropagation).toHaveBeenCalledOnce(); - expect(first.postMessage).not.toHaveBeenCalled(); + 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', @@ -164,6 +1140,14 @@ describe('Universal Creative bridge dispatcher', () => { 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(), @@ -212,4 +1196,1085 @@ describe('Universal Creative bridge dispatcher', () => { 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('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 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(); + 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('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('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).toHaveBeenCalledOnce(); + expect(gam.attempt.accept).not.toHaveBeenCalled(); + + dispatchPortMessage(controlRetained, { + message: 'TS ADM Loaded', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + expect(gam.attempt.accept).toHaveBeenCalledOnce(); + 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('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', + }); + 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).toHaveBeenCalledOnce(); + 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(); + }); + + 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(); + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId, LIFECYCLE_TICKET), + ports: [latePort], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(0); + expect(latePort.postMessage).not.toHaveBeenCalled(); + expect(latePort.close).not.toHaveBeenCalled(); + }); }); diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index 8f41b7d94..b7a1a1675 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -1635,11 +1635,12 @@ collapse those checkpoints or carry unverified behavior between them. - Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` - Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` - Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` -- Create: `crates/trusted-server-integration-tests/browser/fixtures/prebid-universal-creative-1.17.2.js` -- Create: `crates/trusted-server-integration-tests/browser/fixtures/prebid-universal-creative-1.17.2.sha256` -- [ ] **Step 1: Vendor the exact supported PUC 1.17.2 artifact and checksum for hermetic tests;** - the GAM template pins the same version and never `latest`. Add failing tests for +- [ ] **Step 1: Build a locally authored PUC contract harness without copying or vendoring PUC** + **bytes.** Keep it limited to the public `prebidMessenger`, dynamic-renderer, + and `h.sendMessage` behavior exercised by this protocol. The external GAM + configuration selects PUC 1.17.2 and never `latest`; the real-GAM gate, not a + repository artifact, validates that release. Add failing tests for the exact JSON string `{message:"Prebid Request",adId,adServerDomain}`, object/extended shapes, zero/two ports, native id, live/tombstoned TS id, duplicate simultaneous claim, @@ -2750,7 +2751,6 @@ implementation change. - Modify: `crates/trusted-server-integration-tests/browser/helpers/infra.ts` - Modify: `crates/trusted-server-integration-tests/browser/helpers/state.ts` - Modify: `crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js` -- Modify: `crates/trusted-server-integration-tests/browser/fixtures/prebid-universal-creative-1.17.2.js` - Modify: `scripts/integration-tests-browser.sh` - Modify: `.github/workflows/integration-tests.yml` @@ -2763,12 +2763,14 @@ implementation change. `npm --prefix ... exec -- playwright`; it must retain the release-WASM, Viceroy config, Docker image, npm install, and TSJS fixture preparation from Task 0. -- [ ] **Step 2: Create deterministic local GPT and locally authored fictional APS-runner** - success/failure fixtures; run the vendored exact PUC 1.17.2 artifact for the - creative path. The fictional runner must not copy, transform, derive from, or - archive APS runner bytes and must never be packaged as a production fallback. Do - not replace PUC's `prebidMessenger`, `runDynamicRenderer`, or `h.sendMessage` - behavior and do not mock the kernel/services under test. +- [ ] **Step 2: Create deterministic local GPT, PUC-contract, and locally authored** + **fictional APS-runner success/failure fixtures.** The PUC harness implements + only the public `prebidMessenger`, `runDynamicRenderer`, and `h.sendMessage` + behavior required to drive the protocol and contains no copied PUC bytes. The + fictional runner must not copy, transform, derive from, or archive APS runner + bytes and must never be packaged as a production fallback. Do not mock the + kernel/services under test; exercise the actual externally hosted PUC release + only in the real-GAM pre-production gate. - [ ] **Step 3: Implement every spec §7.2 browser-observable race as grouped tables with exact** terminal, DOM, targeting, listener, port, timer, and network assertions: diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index 51751f2f7..bc1cfa365 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1425,14 +1425,17 @@ artifact object. ### 4.2 Universal Creative claim -The supported GAM creative pins Prebid Universal Creative 1.17.2 by exact artifact, -not `latest` or a publisher-selectable version. Its cross-domain request is a JSON +The supported GAM creative selects Prebid Universal Creative 1.17.2 outside the +Trusted Server source tree, never `latest` or a publisher-selectable version. No PUC +bytes, checksum, or distributable artifact is vendored into this repository. Its +cross-domain request is a JSON string decoding to exactly `{message:"Prebid Request",adId,adServerDomain}` and carries exactly one transferred response port. All three values are strings; `adId` and `adServerDomain` are nonempty. Object-form or extended payloads are rejected. Universal Creative owns -this shape, so it cannot carry a TS nonce. The checked-in hermetic PUC fixture is -generated from or pinned byte-for-byte to the supported source behavior. +this shape, so it cannot carry a TS nonce. Hermetic unit and browser tests exercise a +locally authored contract harness limited to that public message/helper behavior; +the pre-production real-GAM conformance gate exercises the actual PUC release. The bridge is one capture-phase dispatcher installed as the first reversible core effect in the synchronous activation barrier, before any integration-module @@ -3098,7 +3101,7 @@ adding a hidden analytics subsystem here. | Risk | Mitigation | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PUC behavior differs from mocks | vendor and checksum the exact supported PUC 1.17.2 behavior, exercise its `h.sendMessage` channel, and gate on real GAM | +| PUC behavior differs from the local contract harness | keep the harness limited to the public message/helper contract, exercise `h.sendMessage`, and gate the actual externally hosted PUC release on real GAM; do not vendor PUC bytes | | Same-realm publisher code can interfere | explicitly trust TS-authored owner code; capability checks defend unrelated frames, replays, and stale work, not arbitrary same-realm compromise | | A module activation never returns | activation is generated first-party code with boundary tests; elapsed returning calls fail through monotonic checks, but JavaScript cannot preempt a nonreturning same-thread function | | Strict parsing rejects a future APS field | descriptor is versioned; outer transport remains tolerant; add a reviewed version/corpus update rather than silently accepting new semantics | From e2157b046752f82c447e8e515ef413f626fc41d3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:05:29 -0700 Subject: [PATCH 058/194] Harden the serialized PUC owner contract --- .../lib/src/services/puc_bridge.ts | 52 ++++++++++++-- .../lib/test/services/puc_bridge.test.ts | 72 ++++++++++++------- 2 files changed, 94 insertions(+), 30 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/puc_bridge.ts b/crates/trusted-server-js/lib/src/services/puc_bridge.ts index 91e0d6d67..bcd345d0d 100644 --- a/crates/trusted-server-js/lib/src/services/puc_bridge.ts +++ b/crates/trusted-server-js/lib/src/services/puc_bridge.ts @@ -217,6 +217,53 @@ function installPucDynamicOwner(): void { } return true; }; + const utf8Length = (value: string): number => new TextEncoder().encode(value).byteLength; + const validApsRenderer = ( + renderer: Record, + publisherOrigin: URL + ): boolean => { + const accountId = renderer['accountId']; + const bidId = renderer['bidId']; + const creativeId = renderer['creativeId']; + const creativeUrl = renderer['creativeUrl']; + const aaxResponse = renderer['aaxResponse']; + if ( + renderer['type'] !== 'aps' || + renderer['version'] !== 1 || + typeof accountId !== 'string' || + accountId.length === 0 || + utf8Length(accountId) > 1024 || + typeof bidId !== 'string' || + bidId.length === 0 || + utf8Length(bidId) > 64 || + /[\x00-\x1f\x7f]/.test(bidId) || + (renderer['tagType'] !== 'iframe' && renderer['tagType'] !== 'script') || + !validDimension(renderer['width']) || + !validDimension(renderer['height']) || + typeof creativeUrl !== 'string' || + utf8Length(creativeUrl) > 4096 || + typeof aaxResponse !== 'string' || + aaxResponse.length > 349_528 || + (Object.prototype.hasOwnProperty.call(renderer, 'creativeId') && + (typeof creativeId !== 'string' || + creativeId.length === 0 || + utf8Length(creativeId) > 1024)) + ) { + return false; + } + try { + const parsedCreativeUrl = new URL(creativeUrl); + return ( + parsedCreativeUrl.protocol === 'https:' && + parsedCreativeUrl.hostname !== '' && + parsedCreativeUrl.username === '' && + parsedCreativeUrl.password === '' && + parsedCreativeUrl.origin !== publisherOrigin.origin + ); + } catch { + return false; + } + }; ownerWindow.render = (data, helper, creativeWindow) => new Promise((resolve, reject) => { @@ -433,12 +480,9 @@ function installPucDynamicOwner(): void { !/^n1_[A-Za-z0-9_-]{22}$/.test(envelope['nonce']) || typeof envelope['publisherOrigin'] !== 'string' || new TextEncoder().encode(envelope['publisherOrigin']).byteLength > 2048 || - renderer['type'] !== 'aps' || - renderer['version'] !== 1 || - !validDimension(renderer['width']) || - !validDimension(renderer['height']) || !parsedUrl || !parsedPublisherOrigin || + !validApsRenderer(renderer, parsedPublisherOrigin) || new TextEncoder().encode(String(rendererUrl)).byteLength > 2048 || (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') || parsedUrl.hostname === '' || 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 index d4002def2..e8fbf8358 100644 --- a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -384,21 +384,23 @@ describe('Universal Creative bridge dispatcher', () => { expect(stopListening).toHaveBeenCalledOnce(); expect(controlPort.start).toHaveBeenCalledOnce(); - controlListener?.({ - data: { - message: 'TS ADM Start', - version: 1, - lifecycleTicket: LIFECYCLE_TICKET, - source: { - type: 'adm', + controlListener?.( + new MessageEvent('message', { + data: { + message: 'TS ADM Start', version: 1, - adm: '
remote creative
', - width: 300, - height: 250, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
remote creative
', + width: 300, + height: 250, + }, }, - }, - ports: [], - }); + ports: [], + }) + ); const frame = document.body.querySelector('iframe'); expect(frame).not.toBeNull(); expect(frame?.srcdoc).toContain('
remote creative
'); @@ -417,15 +419,17 @@ describe('Universal Creative bridge dispatcher', () => { version: 1, lifecycleTicket: LIFECYCLE_TICKET, }); - controlListener?.({ - data: { - message: 'TS Owner Settled', - version: 1, - lifecycleTicket: LIFECYCLE_TICKET, - outcome: 'accepted', - }, - ports: [], - }); + 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); @@ -889,7 +893,18 @@ describe('Universal Creative bridge dispatcher', () => { } }); - it('refuses an APS owner start whose renderer URL is outside the publisher origin', async () => { + it.each([ + { + caseName: 'cross-origin renderer route', + rendererOverrides: {}, + rendererUrl: 'https://attacker.example/integrations/aps/renderer/v1', + }, + { + caseName: 'semantically invalid renderer descriptor', + rendererOverrides: { tagType: 'native' }, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + }, + ])('refuses an APS owner start with a $caseName', async ({ rendererOverrides, rendererUrl }) => { vi.useFakeTimers(); const dynamicWindow = window as unknown as { render?: ( @@ -956,7 +971,7 @@ describe('Universal Creative bridge dispatcher', () => { message: 'TS APS Start', version: 1, lifecycleTicket: LIFECYCLE_TICKET, - rendererUrl: 'https://attacker.example/integrations/aps/renderer/v1', + rendererUrl, envelope: { version: 1, nonce: 'n1_abcdefghijklmnopqrstuv', @@ -971,6 +986,7 @@ describe('Universal Creative bridge dispatcher', () => { width: 300, height: 250, aaxResponse: 'renderer-envelope', + ...rendererOverrides, }, }, }, @@ -1866,7 +1882,8 @@ describe('Universal Creative bridge dispatcher', () => { version: 1, lifecycleTicket: LIFECYCLE_TICKET, }); - expect(gam.attempt.beginAdm).toHaveBeenCalledOnce(); + expect(gam.attempt.beginAdm).toHaveBeenCalledWith(gam.artifact); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); expect(gam.attempt.accept).not.toHaveBeenCalled(); dispatchPortMessage(controlRetained, { @@ -1875,6 +1892,7 @@ describe('Universal Creative bridge dispatcher', () => { 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([ { @@ -2084,7 +2102,8 @@ describe('Universal Creative bridge dispatcher', () => { version: 1, lifecycleTicket: LIFECYCLE_TICKET, }); - expect(gam.attempt.beginApsDocument).toHaveBeenCalledOnce(); + 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(); @@ -2101,6 +2120,7 @@ describe('Universal Creative bridge dispatcher', () => { expect(documentRetained.close).toHaveBeenCalledOnce(); expect(controlTransferred.close).not.toHaveBeenCalled(); expect(documentTransferred.close).not.toHaveBeenCalled(); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); }); it('suppresses, refuses, and invalidates a live ticket used from the wrong source', () => { From 2c61ff684bc7cdc3ac2753e2f072826938ad7bb4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:10:32 -0700 Subject: [PATCH 059/194] Harden PUC owner event boundaries --- .../lib/src/services/puc_bridge.ts | 237 +++++-- .../lib/test/composition/browser.test.ts | 5 +- .../lib/test/services/puc_bridge.test.ts | 654 +++++++++++++++--- 3 files changed, 745 insertions(+), 151 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/puc_bridge.ts b/crates/trusted-server-js/lib/src/services/puc_bridge.ts index bcd345d0d..5ace16d1e 100644 --- a/crates/trusted-server-js/lib/src/services/puc_bridge.ts +++ b/crates/trusted-server-js/lib/src/services/puc_bridge.ts @@ -117,6 +117,8 @@ function installPucDynamicOwner(): void { 'bundle_partial', ]); const cancellationReasons = new Set(['caller_aborted', 'superseded', 'navigation_disposed']); + const messageEventDataGetter = Object.getOwnPropertyDescriptor(MessageEvent.prototype, 'data') + ?.get as ((this: MessageEvent) => unknown) | undefined; const ownDataValue = (candidate: unknown, name: string): unknown => { try { @@ -127,6 +129,16 @@ function installPucDynamicOwner(): void { return undefined; } }; + const eventDataValue = (event: unknown): unknown => { + try { + if (typeof event !== 'object' || event === null) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(event, 'data'); + if (descriptor) return 'value' in descriptor ? descriptor.value : undefined; + return messageEventDataGetter ? Reflect.apply(messageEventDataGetter, event, []) : undefined; + } catch { + return undefined; + } + }; const exactRecord = ( candidate: unknown, @@ -151,37 +163,147 @@ function installPucDynamicOwner(): void { } return candidate as Record; }; - const eventPorts = (event: unknown, count: number): MessagePort[] | undefined => { + const snapshotEventPorts = (event: unknown): MessagePort[] | undefined => { try { if (typeof event !== 'object' || event === null) return undefined; const ports = Reflect.get(event, 'ports') as unknown; - if (!Array.isArray(ports) || ports.length !== count) return undefined; - for (let index = 0; index < ports.length; index += 1) { - const port = ports[index] as Partial | undefined; - if (!port || typeof port.postMessage !== 'function' || typeof port.close !== 'function') { + if ( + !Array.isArray(ports) || + Object.getPrototypeOf(ports) !== Array.prototype || + Object.getOwnPropertySymbols(ports).length !== 0 + ) { + return undefined; + } + const length = Object.getOwnPropertyDescriptor(ports, 'length'); + if ( + !length || + !('value' in length) || + !Number.isSafeInteger(length.value) || + length.value < 0 || + Object.getOwnPropertyNames(ports).length !== length.value + 1 + ) { + return undefined; + } + const snapshot: MessagePort[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(ports, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + const port = descriptor.value as Partial | undefined; + if ( + !port || + typeof Reflect.get(port, 'postMessage') !== 'function' || + typeof Reflect.get(port, 'close') !== 'function' + ) { return undefined; } + snapshot[index] = port as MessagePort; } - return ports as MessagePort[]; + return snapshot; } catch { return undefined; } }; + const eventPorts = (event: unknown, count: number): MessagePort[] | undefined => { + const ports = snapshotEventPorts(event); + return ports?.length === count ? ports : undefined; + }; const closeEventPorts = (event: unknown): void => { - try { - if (typeof event !== 'object' || event === null) return; - const ports = Reflect.get(event, 'ports') as unknown; - if (!Array.isArray(ports)) return; - for (let index = 0; index < ports.length; index += 1) { + const ports = snapshotEventPorts(event); + if (!ports) return; + for (let index = 0; index < ports.length; index += 1) { + try { + ports[index]?.close(); + } catch { + // Late or malformed endpoints are still contained independently. + } + } + }; + const skipJsonWhitespace = (source: string, start: number): number => { + let index = start; + while ( + source[index] === ' ' || + source[index] === '\t' || + source[index] === '\n' || + source[index] === '\r' + ) { + index += 1; + } + return index; + }; + const scanJsonString = (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; + }; + const scanJsonValue = (source: string, start: number): number | undefined => { + let index = skipJsonWhitespace(source, start); + if (source[index] === '"') return scanJsonString(source, index); + if (source[index] === '[') { + index = skipJsonWhitespace(source, index + 1); + if (source[index] === ']') return index + 1; + while (index < source.length) { + const end = scanJsonValue(source, index); + if (end === undefined) return undefined; + index = skipJsonWhitespace(source, end); + if (source[index] === ']') return index + 1; + if (source[index] !== ',') return undefined; + index = skipJsonWhitespace(source, index + 1); + } + return undefined; + } + if (source[index] === '{') { + const keys = new Set(); + index = skipJsonWhitespace(source, index + 1); + if (source[index] === '}') return index + 1; + while (index < source.length) { + const keyEnd = scanJsonString(source, index); + if (keyEnd === undefined) return undefined; + let key: unknown; try { - const port = ports[index] as Partial | undefined; - if (typeof port?.close === 'function') port.close(); + key = JSON.parse(source.slice(index, keyEnd)) as unknown; } catch { - // Late or malformed endpoints are still contained independently. + return undefined; } + if (typeof key !== 'string' || keys.has(key)) return undefined; + keys.add(key); + index = skipJsonWhitespace(source, keyEnd); + if (source[index] !== ':') return undefined; + const valueEnd = scanJsonValue(source, index + 1); + if (valueEnd === undefined) return undefined; + index = skipJsonWhitespace(source, valueEnd); + if (source[index] === '}') return index + 1; + if (source[index] !== ',') return undefined; + index = skipJsonWhitespace(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; + }; + const parseJsonWithoutDuplicateKeys = (source: string): unknown => { + const end = scanJsonValue(source, 0); + if (end === undefined || skipJsonWhitespace(source, end) !== source.length) return undefined; + try { + return JSON.parse(source) as unknown; } catch { - // A hostile event cannot interrupt terminal cleanup. + return undefined; } }; const parseRegistration = (value: unknown): Record | undefined => { @@ -189,7 +311,7 @@ function installPucDynamicOwner(): void { if (typeof value !== 'string' || new TextEncoder().encode(value).byteLength > 4096) { return undefined; } - return exactRecord(JSON.parse(value) as unknown, [ + return exactRecord(parseJsonWithoutDuplicateKeys(value), [ 'message', 'adId', 'version', @@ -218,10 +340,12 @@ function installPucDynamicOwner(): void { return true; }; const utf8Length = (value: string): number => new TextEncoder().encode(value).byteLength; - const validApsRenderer = ( - renderer: Record, - publisherOrigin: URL - ): boolean => { + const containsAsciiControl = (value: string): boolean => + Array.from(value).some((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f); + }); + const validApsRenderer = (renderer: Record, publisherOrigin: URL): boolean => { const accountId = renderer['accountId']; const bidId = renderer['bidId']; const creativeId = renderer['creativeId']; @@ -236,7 +360,7 @@ function installPucDynamicOwner(): void { typeof bidId !== 'string' || bidId.length === 0 || utf8Length(bidId) > 64 || - /[\x00-\x1f\x7f]/.test(bidId) || + containsAsciiControl(bidId) || (renderer['tagType'] !== 'iframe' && renderer['tagType'] !== 'script') || !validDimension(renderer['width']) || !validDimension(renderer['height']) || @@ -284,6 +408,7 @@ function installPucDynamicOwner(): void { } const adId = outer?.['adId']; const lifecycleTicket = owner?.['lifecycleTicket']; + const ownerKind = owner?.['kind']; if ( !outer || !owner || @@ -311,6 +436,7 @@ function installPucDynamicOwner(): void { let controlPort: MessagePort | undefined; let documentPort: MessagePort | undefined; let frame: HTMLIFrameElement | undefined; + let ownerFrameCurrent: (() => boolean) | undefined; let frameCommitted = false; let localApsFailure = false; let started = false; @@ -406,8 +532,14 @@ function installPucDynamicOwner(): void { } prepareDocument(); const next = configureFrame(source, admSandbox); + const intendedSource = `${source['adm'] as string}`; + ownerFrameCurrent = () => + frame === next && + next.parentNode === creativeWindow.document.body && + next.srcdoc === intendedSource && + next.getAttribute('src') === null; next.onload = () => { - if (!settled && frame === next && next.isConnected) { + if (!settled && ownerFrameCurrent?.() === true) { postControl({ message: 'TS ADM Loaded', version: 1, @@ -424,7 +556,7 @@ function installPucDynamicOwner(): void { }); } }; - next.srcdoc = `${source['adm'] as string}`; + next.srcdoc = intendedSource; frame = next; creativeWindow.document.body.appendChild(next); postControl({ @@ -511,6 +643,13 @@ function installPucDynamicOwner(): void { prepareDocument(); documentPort = ports[0]; const next = configureFrame(renderer, apsSandbox); + const intendedSource = `${parsedUrl.href}#tsaps=${envelope['nonce'] as string}`; + let intendedWindow: Window | null = null; + ownerFrameCurrent = () => + frame === next && + next.parentNode === creativeWindow.document.body && + next.getAttribute('src') === intendedSource && + next.contentWindow === intendedWindow; const containLocalFailure = (transferred?: MessagePort): void => { localApsFailure = true; next.onload = null; @@ -523,7 +662,7 @@ function installPucDynamicOwner(): void { next.remove(); }; next.onload = () => { - if (settled || frame !== next || !next.isConnected || !documentPort) return; + if (settled || ownerFrameCurrent?.() !== true || !documentPort) return; const transferred = documentPort; documentPort = undefined; try { @@ -535,9 +674,10 @@ function installPucDynamicOwner(): void { } }; next.onerror = () => containLocalFailure(); - next.src = `${parsedUrl.href}#tsaps=${envelope['nonce'] as string}`; + next.src = intendedSource; frame = next; creativeWindow.document.body.appendChild(next); + intendedWindow = next.contentWindow; postControl({ message: 'TS Owner Inserted', version: 1, @@ -550,7 +690,7 @@ function installPucDynamicOwner(): void { return; } const ports = eventPorts(event, 0) ?? eventPorts(event, 1); - const dataValue = ownDataValue(event, 'data'); + const dataValue = eventDataValue(event); const routedMessage = ownDataValue(dataValue, 'message'); const routedOutcome = ownDataValue(dataValue, 'outcome'); const message = exactRecord(dataValue, [ @@ -577,18 +717,32 @@ function installPucDynamicOwner(): void { finish(false, 'TS render owner control refused'); return; } - if (message['message'] === 'TS ADM Start' && ports.length === 0 && !started) { + if ( + message['message'] === 'TS ADM Start' && + ownerKind === 'adm' && + ports.length === 0 && + !started + ) { started = true; insertAdm(message['source'] as Record); return; } - if (message['message'] === 'TS APS Start' && ports.length === 1 && !started) { + if ( + message['message'] === 'TS APS Start' && + ownerKind === 'aps' && + ports.length === 1 && + !started + ) { started = true; insertAps(message, ports); return; } if (message['message'] === 'TS Owner Settled' && ports.length === 0) { - if (message['outcome'] === 'accepted' && !localApsFailure && frame && frame.isConnected) { + if ( + message['outcome'] === 'accepted' && + !localApsFailure && + ownerFrameCurrent?.() === true + ) { frameCommitted = true; finish(true, ''); return; @@ -622,13 +776,7 @@ function installPucDynamicOwner(): void { stopHelper(); if (registrationTimer !== undefined) creativeWindow.clearTimeout(registrationTimer); const ports = eventPorts(event, 1); - let dataValue: unknown; - try { - dataValue = - typeof event === 'object' && event !== null ? Reflect.get(event, 'data') : undefined; - } catch { - dataValue = undefined; - } + const dataValue = eventDataValue(event); const response = parseRegistration(dataValue); if ( !ports || @@ -2083,12 +2231,14 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { ): void => { const ticket = routing.lifecycleTicket; if (!ticket) return; - const now = readNow(); - if (now === undefined) return; - pruneExpiredTickets(now); const entry = mapValue(tickets, ticket); if (!entry) return; if (!suppress(event)) return; + const now = readNow(); + if (now !== undefined) { + pruneExpiredTickets(now); + if (mapValue(tickets, ticket) !== entry) return; + } const exact = messaging.parseProtocolMessage('ownerRegister', data); const inspection = messaging.inspectTransferredPorts(event); @@ -2103,6 +2253,15 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { if (port) closePort(port); } }; + if (now === undefined) { + if (responsePort) refuseOwner(responsePort, routing.adId ?? ''); + closeAdditionalPorts(); + if (entry.state !== 'tombstone') { + retireTicket(entry.binding); + failBinding(entry.binding, 'internal_error', false); + } + return; + } if (entry.state === 'tombstone') { if (responsePort) refuseOwner(responsePort, routing.adId ?? ''); closeAdditionalPorts(); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index b55c64705..17ff5a40f 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -285,7 +285,10 @@ describe('browser composition', () => { { adapters: { googletag, - messaging: fakeMessagingAdapter(), + messaging: fakeMessagingAdapter(() => { + expect(subscriptions).toEqual([]); + return vi.fn(); + }), prebid: fakePrebidAdapter(), }, coreActivations: { 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 index e8fbf8358..67684a8d5 100644 --- a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -313,12 +313,132 @@ describe('Universal Creative bridge dispatcher', () => { expect(admStart).toBeGreaterThanOrEqual(0); expect(apsStart).toBeGreaterThan(admStart); expect(controlStart).toBeGreaterThan(apsStart); - expect(admOwner.indexOf('next.onload =')).toBeLessThan(admOwner.indexOf('next.srcdoc =')); - expect(admOwner.indexOf('next.onerror =')).toBeLessThan(admOwner.indexOf('next.srcdoc =')); - expect(apsOwner.indexOf('next.onload =')).toBeLessThan(apsOwner.indexOf('next.src =')); - expect(apsOwner.indexOf('next.onerror =')).toBeLessThan(apsOwner.indexOf('next.src =')); + 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'])( + '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; + }, + }); + } + registrationCallback?.({ + 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, + }); + + expect(controlPort.start).not.toHaveBeenCalled(); + expect(portAccessorCalls).toBe(0); + 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('runs the checked-in PUC owner through helper registration and final ADM settlement', async () => { const dynamicWindow = window as unknown as { render?: ( @@ -372,15 +492,17 @@ describe('Universal Creative bridge dispatcher', () => { { version: 1, lifecycleTicket: LIFECYCLE_TICKET }, expect.any(Function) ); - registrationCallback?.({ - data: JSON.stringify({ - message: 'TS Render Owner Registered', - adId: RESERVATION_ID, - version: 1, - lifecycleTicket: LIFECYCLE_TICKET, - }), - ports: [controlPort], - }); + 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(); @@ -440,6 +562,114 @@ describe('Universal Creative bridge dispatcher', () => { } }); + 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('accepts the optional APS creative id and preserves no-referrer on the owner iframe', async () => { const dynamicWindow = window as unknown as { render?: ( @@ -896,119 +1126,130 @@ describe('Universal Creative bridge dispatcher', () => { 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', }, - ])('refuses an APS owner start with a $caseName', async ({ 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: 'aps', - lifecycleTicket: LIFECYCLE_TICKET, - }, - }) - ) as Readonly>, - { sendMessage }, - window + { + 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(); + } ); - 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: { + 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, - nonce: 'n1_abcdefghijklmnopqrstuv', - publisherOrigin: 'https://publisher.example', - renderer: { - type: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS APS Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + rendererUrl, + envelope: { version: 1, - accountId: 'publisher-account', - bidId: 'bid-1', - tagType: 'iframe', - creativeUrl: 'https://creative.example/render', - width: 300, - height: 250, - aaxResponse: 'renderer-envelope', - ...rendererOverrides, + 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' - ); + 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 = ''; + 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 })); @@ -1772,6 +2013,45 @@ describe('Universal Creative bridge dispatcher', () => { 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' }); @@ -1828,6 +2108,52 @@ describe('Universal Creative bridge dispatcher', () => { 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' }); @@ -1906,6 +2232,53 @@ describe('Universal Creative bridge dispatcher', () => { 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' }); @@ -2123,6 +2496,65 @@ describe('Universal Creative bridge dispatcher', () => { expect(gam.artifact.dispose).not.toHaveBeenCalled(); }); + 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' }); From 067ed54e309656961818cf2da31bb6c5360464db Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:37:23 -0700 Subject: [PATCH 060/194] Harden Universal Creative race boundaries --- .../lib/src/adapters/messaging.ts | 12 +- .../lib/src/services/puc_bridge.ts | 208 ++++-- .../lib/test/adapters/messaging.test.ts | 8 +- .../lib/test/composition/browser.test.ts | 47 +- .../lib/test/services/puc_bridge.test.ts | 590 +++++++++++++++--- 5 files changed, 707 insertions(+), 158 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/messaging.ts b/crates/trusted-server-js/lib/src/adapters/messaging.ts index eb8f9c425..18f3734a5 100644 --- a/crates/trusted-server-js/lib/src/adapters/messaging.ts +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -218,7 +218,7 @@ export interface MessagingAdapter { targetOrigin: string, transferred: readonly MessagingPort[] ): boolean; - installCaptureListener(listener: CaptureMessageListener): () => void; + installCaptureListener(listener: CaptureMessageListener): (() => void) | undefined; inspectGlobalMessage(candidate: unknown): | Readonly<{ message: string; @@ -1381,16 +1381,16 @@ export function createBrowserMessagingAdapter( return Object.freeze({ createChannel: () => createChannel(target), postWindow, - installCaptureListener(listener: CaptureMessageListener): () => void { + 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; + return undefined; } - if (typeof add !== 'function' || typeof remove !== 'function') return () => undefined; + if (typeof add !== 'function' || typeof remove !== 'function') return undefined; const wrapped: CaptureMessageListener = (event): void => { try { listener(event); @@ -1413,7 +1413,7 @@ export function createBrowserMessagingAdapter( Reflect.apply(add, target, ['message', wrapped, true]); } catch { rollback(); - return () => undefined; + return undefined; } return () => { rollback(); @@ -1432,7 +1432,7 @@ export function createNoopMessagingAdapter(): MessagingAdapter { return Object.freeze({ createChannel: () => undefined, postWindow: () => false, - installCaptureListener: () => () => undefined, + installCaptureListener: () => undefined, inspectGlobalMessage, parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => parseProtocolMessage(kind, candidate, {}), diff --git a/crates/trusted-server-js/lib/src/services/puc_bridge.ts b/crates/trusted-server-js/lib/src/services/puc_bridge.ts index 5ace16d1e..8ea00a88a 100644 --- a/crates/trusted-server-js/lib/src/services/puc_bridge.ts +++ b/crates/trusted-server-js/lib/src/services/puc_bridge.ts @@ -119,6 +119,8 @@ function installPucDynamicOwner(): void { const cancellationReasons = new Set(['caller_aborted', 'superseded', 'navigation_disposed']); const messageEventDataGetter = Object.getOwnPropertyDescriptor(MessageEvent.prototype, 'data') ?.get as ((this: MessageEvent) => unknown) | undefined; + const messageEventPortsGetter = Object.getOwnPropertyDescriptor(MessageEvent.prototype, 'ports') + ?.get as ((this: MessageEvent) => readonly MessagePort[]) | undefined; const ownDataValue = (candidate: unknown, name: string): unknown => { try { @@ -163,56 +165,88 @@ function installPucDynamicOwner(): void { } return candidate as Record; }; - const snapshotEventPorts = (event: unknown): MessagePort[] | undefined => { + const inspectEventPorts = ( + event: unknown + ): + | Readonly<{ + exactShape: boolean; + originalCount: number; + ports: readonly MessagePort[]; + }> + | undefined => { try { if (typeof event !== 'object' || event === null) return undefined; - const ports = Reflect.get(event, 'ports') as unknown; - if ( - !Array.isArray(ports) || - Object.getPrototypeOf(ports) !== Array.prototype || - Object.getOwnPropertySymbols(ports).length !== 0 - ) { - return undefined; - } + const descriptor = Object.getOwnPropertyDescriptor(event, 'ports'); + const ports = descriptor + ? 'value' in descriptor + ? descriptor.value + : undefined + : messageEventPortsGetter + ? Reflect.apply(messageEventPortsGetter, event, []) + : undefined; + if (!Array.isArray(ports)) return undefined; const length = Object.getOwnPropertyDescriptor(ports, 'length'); if ( !length || !('value' in length) || !Number.isSafeInteger(length.value) || - length.value < 0 || - Object.getOwnPropertyNames(ports).length !== length.value + 1 + length.value < 0 ) { return undefined; } + let exactShape = + Object.getPrototypeOf(ports) === Array.prototype && + Object.getOwnPropertySymbols(ports).length === 0 && + Object.getOwnPropertyNames(ports).length === length.value + 1; const snapshot: MessagePort[] = []; + const seen = new Set(); for (let index = 0; index < length.value; index += 1) { const descriptor = Object.getOwnPropertyDescriptor(ports, String(index)); - if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + exactShape = false; + continue; + } const port = descriptor.value as Partial | undefined; - if ( - !port || - typeof Reflect.get(port, 'postMessage') !== 'function' || - typeof Reflect.get(port, 'close') !== 'function' - ) { - return undefined; + let validPort = false; + try { + validPort = + !!port && + typeof Reflect.get(port, 'postMessage') === 'function' && + typeof Reflect.get(port, 'close') === 'function'; + } catch { + validPort = false; + } + if (!port || !validPort) { + exactShape = false; + continue; + } + const accepted = port as MessagePort; + if (seen.has(accepted)) { + exactShape = false; + continue; } - snapshot[index] = port as MessagePort; + seen.add(accepted); + snapshot[snapshot.length] = accepted; } - return snapshot; + return { exactShape, originalCount: length.value, ports: snapshot }; } catch { return undefined; } }; const eventPorts = (event: unknown, count: number): MessagePort[] | undefined => { - const ports = snapshotEventPorts(event); - return ports?.length === count ? ports : undefined; + const inspection = inspectEventPorts(event); + return inspection?.exactShape === true && + inspection.originalCount === count && + inspection.ports.length === count + ? [...inspection.ports] + : undefined; }; const closeEventPorts = (event: unknown): void => { - const ports = snapshotEventPorts(event); - if (!ports) return; - for (let index = 0; index < ports.length; index += 1) { + const inspection = inspectEventPorts(event); + if (!inspection) return; + for (let index = 0; index < inspection.ports.length; index += 1) { try { - ports[index]?.close(); + inspection.ports[index]?.close(); } catch { // Late or malformed endpoints are still contained independently. } @@ -443,8 +477,31 @@ function installPucDynamicOwner(): void { const removeFrameHandlers = (): void => { if (!frame) return; - frame.onload = null; - frame.onerror = null; + try { + frame.onload = null; + } catch { + // One hostile DOM setter cannot skip the remaining terminal cleanup. + } + try { + frame.onerror = null; + } catch { + // One hostile DOM setter cannot skip the remaining terminal cleanup. + } + }; + const clearTimer = (handle: number | undefined): void => { + if (handle === undefined) return; + try { + creativeWindow.clearTimeout(handle); + } catch { + // Timer cleanup cannot prevent channel cleanup or Promise settlement. + } + }; + const removeFrame = (candidate: HTMLIFrameElement | undefined): void => { + try { + candidate?.remove(); + } catch { + // DOM cleanup is best-effort after authority is already terminal. + } }; const closePort = (port: MessagePort | undefined): void => { try { @@ -465,21 +522,33 @@ function installPucDynamicOwner(): void { const finish = (accepted: boolean, reason: string): void => { if (settled) return; settled = true; - if (registrationTimer !== undefined) creativeWindow.clearTimeout(registrationTimer); - if (ownerTimer !== undefined) creativeWindow.clearTimeout(ownerTimer); - stopHelper(); - removeFrameHandlers(); - if (!accepted && frame && !frameCommitted) frame.remove(); - if (controlPort) { - controlPort.onmessage = null; - controlPort.onmessageerror = null; + try { + clearTimer(registrationTimer); + clearTimer(ownerTimer); + stopHelper(); + removeFrameHandlers(); + if (!accepted && frame && !frameCommitted) removeFrame(frame); + if (controlPort) { + try { + controlPort.onmessage = null; + } catch { + // One hostile handler setter cannot retain the remaining authority. + } + try { + controlPort.onmessageerror = null; + } catch { + // One hostile handler setter cannot retain the remaining authority. + } + } + closePort(documentPort); + closePort(controlPort); + documentPort = undefined; + controlPort = undefined; + ownerFrameCurrent = undefined; + } finally { + if (accepted) resolve(); + else reject(new Error(reason)); } - closePort(documentPort); - closePort(controlPort); - documentPort = undefined; - controlPort = undefined; - if (accepted) resolve(); - else reject(new Error(reason)); }; const postControl = (message: Record): boolean => { try { @@ -652,14 +721,22 @@ function installPucDynamicOwner(): void { next.contentWindow === intendedWindow; const containLocalFailure = (transferred?: MessagePort): void => { localApsFailure = true; - next.onload = null; - next.onerror = null; + try { + next.onload = null; + } catch { + // Local containment continues through hostile DOM setters. + } + try { + next.onerror = null; + } catch { + // Local containment continues through hostile DOM setters. + } closePort(transferred); if (documentPort) { closePort(documentPort); documentPort = undefined; } - next.remove(); + removeFrame(next); }; next.onload = () => { if (settled || ownerFrameCurrent?.() !== true || !documentPort) return; @@ -774,7 +851,7 @@ function installPucDynamicOwner(): void { } registrationFinished = true; stopHelper(); - if (registrationTimer !== undefined) creativeWindow.clearTimeout(registrationTimer); + clearTimer(registrationTimer); const ports = eventPorts(event, 1); const dataValue = eventDataValue(event); const response = parseRegistration(dataValue); @@ -918,6 +995,7 @@ interface GamAttemptBinding { active: boolean; claim: PendingClaim | undefined; claimDeadlineHandle: unknown; + claimDeadlineToken: object | undefined; controlListenerDispose: (() => void) | undefined; controlPort: MessagingPort | undefined; controlStarted: boolean; @@ -1490,6 +1568,7 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { const clearClaimDeadline = (binding: GamAttemptBinding): void => { const handle = binding.claimDeadlineHandle; binding.claimDeadlineHandle = undefined; + binding.claimDeadlineToken = undefined; clearScheduled(handle); }; @@ -1814,18 +1893,22 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { }; const armClaimDeadline = (binding: GamAttemptBinding): boolean => { - if (!binding.active || binding.claimDeadlineHandle !== undefined) return false; + if (!binding.active || binding.claimDeadlineToken !== undefined) return false; + const token = frozen({}); + binding.claimDeadlineToken = token; let handle: unknown; try { handle = Reflect.apply(schedulerSet, scheduler, [ () => { + if (binding.claimDeadlineToken !== token) return; + binding.claimDeadlineToken = undefined; + binding.claimDeadlineHandle = undefined; if ( binding.active && binding.gamReady && !binding.claim && - mapValue(attempts, binding.reservationId) === binding + currentBindingState(binding, 'waiting_for_gam_and_claim') ) { - binding.claimDeadlineHandle = undefined; failBinding(binding, 'bridge_claim_timeout', false); } }, @@ -1834,9 +1917,12 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { } catch { handle = undefined; } - if (handle === undefined || !binding.active) { + if (handle === undefined || !binding.active || binding.claimDeadlineToken !== token) { clearScheduled(handle); - if (binding.active) failBinding(binding, 'internal_error', false); + if (binding.active && binding.claimDeadlineToken === token) { + binding.claimDeadlineToken = undefined; + failBinding(binding, 'internal_error', false); + } return false; } binding.claimDeadlineHandle = handle; @@ -2112,7 +2198,9 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { if (completed?.['nonce'] === nonce) { if (!binding.documentAccepted) { if (binding.documentAcceptancePending) { - binding.documentTerminalPending = 'completed'; + if (binding.documentTerminalPending === undefined) { + binding.documentTerminalPending = 'completed'; + } return; } failBinding(binding, 'renderer_document_no_load', false); @@ -2138,7 +2226,9 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { ? reason : 'winner_not_renderable'; if (!binding.documentAccepted && binding.documentAcceptancePending) { - binding.documentTerminalPending = mapped; + if (binding.documentTerminalPending === undefined) { + binding.documentTerminalPending = mapped; + } return; } failBinding(binding, mapped, false); @@ -2235,9 +2325,10 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { if (!entry) return; if (!suppress(event)) return; const now = readNow(); + let entryStillCurrent = true; if (now !== undefined) { pruneExpiredTickets(now); - if (mapValue(tickets, ticket) !== entry) return; + entryStillCurrent = mapValue(tickets, ticket) === entry; } const exact = messaging.parseProtocolMessage('ownerRegister', data); @@ -2253,6 +2344,11 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { if (port) closePort(port); } }; + if (!entryStillCurrent) { + if (responsePort) refuseOwner(responsePort, routing.adId ?? ''); + closeAdditionalPorts(); + return; + } if (now === undefined) { if (responsePort) refuseOwner(responsePort, routing.adId ?? ''); closeAdditionalPorts(); @@ -2421,6 +2517,9 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { }; const uninstall = messaging.installCaptureListener(dispatch); + if (typeof uninstall !== 'function') { + throw new Error('Universal Creative capture listener installation failed'); + } const bridge: PucBridge = { registerGamAttempt(input): boolean { @@ -2473,6 +2572,7 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { active: true, claim: undefined, claimDeadlineHandle: undefined, + claimDeadlineToken: undefined, controlListenerDispose: undefined, controlPort: undefined, controlStarted: false, diff --git a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts index a2701f620..dbcf2eaf3 100644 --- a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts @@ -1551,8 +1551,9 @@ describe('browser messaging adapter', () => { const dispose = adapter.installCaptureListener(() => { throw new Error('capture failed'); }); + expect(dispose).toBeTypeOf('function'); expect(() => installed[0]?.({} as MessageEvent)).not.toThrow(); - expect(() => dispose()).not.toThrow(); + expect(() => dispose?.()).not.toThrow(); const raw = createPort(); raw.postMessage.mockImplementation(() => { @@ -1579,7 +1580,7 @@ describe('browser messaging adapter', () => { }, removeEventListener: vi.fn(), }); - expect(() => throwingTarget.installCaptureListener(vi.fn())).not.toThrow(); + expect(throwingTarget.installCaptureListener(vi.fn())).toBeUndefined(); }); it('rolls back the exact capture listener when installation throws after adding it', () => { @@ -1604,8 +1605,7 @@ describe('browser messaging adapter', () => { expect(listeners.size).toBe(0); expect(removeEventListener).toHaveBeenCalledTimes(1); expect(removeEventListener).toHaveBeenCalledWith('message', installed, true); - expect(() => dispose()).not.toThrow(); - expect(() => dispose()).not.toThrow(); + expect(dispose).toBeUndefined(); expect(removeEventListener).toHaveBeenCalledTimes(1); }); }); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 17ff5a40f..1939f954e 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -117,14 +117,15 @@ describe('browser composition', () => { 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(); + dispose?.(); + dispose?.(); expect(target.removeEventListener).toHaveBeenCalledTimes(1); expect(target.removeEventListener).toHaveBeenCalledWith('message', installed, true); }); @@ -149,7 +150,8 @@ describe('browser composition', () => { expect(composition.adapters.googletag.bindingStatus()).toBe('pending'); expect(composition.adapters.prebid.bindingStatus()).toBe('pending'); - expect(() => composition.adapters.messaging.installCaptureListener(listener)()).not.toThrow(); + const disposeMessaging = composition.adapters.messaging.installCaptureListener(listener); + expect(disposeMessaging).toBeUndefined(); expect(listener).not.toHaveBeenCalled(); }); @@ -466,6 +468,45 @@ describe('browser composition', () => { 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: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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( 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 index 67684a8d5..d833cde63 100644 --- a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -343,101 +343,240 @@ describe('Universal Creative bridge dispatcher', () => { expect(PUC_DYNAMIC_OWNER).toContain('ownerFrameCurrent?.() === true'); }); - it.each(['duplicate registration key', 'accessor-backed registration port'])( - '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; + 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: { + 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, - 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; - }, - }); - } - registrationCallback?.({ - 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, + }), + 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); - 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 = ''; + 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 { @@ -670,6 +809,153 @@ describe('Universal Creative bridge dispatcher', () => { } }); + 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?: ( @@ -1435,6 +1721,111 @@ describe('Universal Creative bridge dispatcher', () => { 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) => { @@ -1614,6 +2005,10 @@ describe('Universal Creative bridge dispatcher', () => { 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(), @@ -1626,6 +2021,8 @@ describe('Universal Creative bridge dispatcher', () => { 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); @@ -2466,6 +2863,12 @@ describe('Universal Creative bridge dispatcher', () => { 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 @@ -2719,14 +3122,19 @@ describe('Universal Creative bridge dispatcher', () => { now = 3_000; const latePort = createPort(); + const stopImmediatePropagation = vi.fn(); harness.dispatch({ data: exactOwnerRegistration(gam.reservationId, LIFECYCLE_TICKET), ports: [latePort], source: Object.freeze({}), - stopImmediatePropagation: vi.fn(), + stopImmediatePropagation, }); expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(0); - expect(latePort.postMessage).not.toHaveBeenCalled(); - expect(latePort.close).not.toHaveBeenCalled(); + 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(); }); }); From 7c0544d79a33d605d3fba55cd03cdc30a0cf211c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:38:18 -0700 Subject: [PATCH 061/194] Cover APS buffered terminal ordering --- .../lib/test/services/puc_bridge.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) 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 index d833cde63..ccaea6d69 100644 --- a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -2899,6 +2899,100 @@ describe('Universal Creative bridge dispatcher', () => { 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' }); From 6404b0b0d07420e0a512f164ae7725d0246c2e56 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:41:46 -0700 Subject: [PATCH 062/194] Implement navigation-owned auction batches --- .../lib/src/services/auction_batch.ts | 564 +++++++++++++++++ .../lib/test/services/auction_batch.test.ts | 566 ++++++++++++++++++ 2 files changed, 1130 insertions(+) create mode 100644 crates/trusted-server-js/lib/src/services/auction_batch.ts create mode 100644 crates/trusted-server-js/lib/test/services/auction_batch.test.ts diff --git a/crates/trusted-server-js/lib/src/services/auction_batch.ts b/crates/trusted-server-js/lib/src/services/auction_batch.ts new file mode 100644 index 000000000..91ce304a9 --- /dev/null +++ b/crates/trusted-server-js/lib/src/services/auction_batch.ts @@ -0,0 +1,564 @@ +import type { NavigationSession, RenderAttemptScope, WinnerContext } from '../kernel/sessions'; + +import type { + RenderAttempt, + RenderAttemptCreationResult, + RenderCancellationReason, + RenderFailureReason, + RenderOutcome, +} from './render'; + +const DEFAULT_AUCTION_ENDPOINT = '/auction'; + +export type AuctionBatchFetcher = (input: string, init: RequestInit) => Promise; + +export interface AuctionBatchBid { + readonly candidateId: string; + readonly rendererReservationId: string; + readonly impid: string; + readonly provider: string; + readonly price: number; + readonly width: number; + readonly height: number; + readonly renderSource: unknown; + readonly adm?: string | undefined; +} + +export type AuctionBatchDecision = + | Readonly<{ slot: string; outcome: 'winner'; candidateId: string }> + | Readonly<{ slot: string; outcome: 'no_bid' }> + | Readonly<{ slot: string; outcome: 'failed'; reason: RenderFailureReason }>; + +export interface ParsedAuctionBatchResponse { + readonly auction: Readonly<{ + readonly results: readonly AuctionBatchDecision[]; + }>; + readonly bids: readonly AuctionBatchBid[]; +} + +export interface AuctionBatchScheduler { + readonly clear: (handle: unknown) => void; + readonly set: (callback: () => void, milliseconds: number) => unknown; +} + +export type AuctionBatchSlotResult = Readonly<{ slot: string; path: 'primary' } & RenderOutcome>; + +export interface AuctionBatchResult { + readonly slots: readonly AuctionBatchSlotResult[]; +} + +export interface AuctionBatch { + readonly result: Promise>; + readonly cancel: () => void; +} + +export interface AuctionBatchInput { + readonly navigation: NavigationSession; + readonly requestBody: string; + readonly signal?: AbortSignal; + readonly slots: readonly string[]; + readonly timeoutMs: number; +} + +export interface AuctionBatchServiceOptions { + readonly cachePolicy?: unknown; + readonly createAttempt: (owner: RenderAttemptScope) => RenderAttemptCreationResult; + readonly endpoint?: string; + readonly fetcher: AuctionBatchFetcher; + readonly parseResponse: ( + value: unknown, + cachePolicy?: unknown + ) => ParsedAuctionBatchResponse | undefined; + readonly renderWinner: (attempt: RenderAttempt, bid: AuctionBatchBid) => boolean; + readonly scheduler?: AuctionBatchScheduler; +} + +export interface AuctionBatchService { + readonly create: (input: AuctionBatchInput) => AuctionBatch; + readonly dispose: () => void; +} + +interface ActiveChild { + readonly attempt: RenderAttempt; + readonly navigationGeneration: object; + terminal: boolean; +} + +interface BatchChild extends ActiveChild { + readonly index: number; + readonly slot: string; +} + +function frozen(value: Value): Readonly { + return Object.freeze(value); +} + +function defaultScheduler(): AuctionBatchScheduler { + return frozen({ + clear: (handle: unknown): void => + globalThis.clearTimeout(handle as ReturnType), + set: (callback: () => void, milliseconds: number): unknown => + globalThis.setTimeout(callback, milliseconds), + }); +} + +function terminalResult(slot: string, outcome: RenderOutcome): AuctionBatchSlotResult { + return frozen({ slot, path: 'primary' as const, ...outcome }); +} + +function failedResult(slot: string, reason: RenderFailureReason): AuctionBatchSlotResult { + return terminalResult(slot, frozen({ outcome: 'failed' as const, reason })); +} + +function cancelledResult(slot: string, reason: RenderCancellationReason): AuctionBatchSlotResult { + return terminalResult(slot, frozen({ outcome: 'cancelled' as const, reason })); +} + +function responseMembershipIsExact( + parsed: ParsedAuctionBatchResponse, + slots: readonly string[] +): boolean { + const decisions = parsed.auction.results; + if (decisions.length !== slots.length) return false; + const membership = new Set(slots); + if (membership.size !== slots.length) return false; + const observed = new Set(); + for (let index = 0; index < decisions.length; index += 1) { + const slot = decisions[index]?.slot; + if (!slot || !membership.has(slot) || observed.has(slot)) return false; + observed.add(slot); + } + return observed.size === membership.size; +} + +/** Runtime-owned coordinator for navigation-scoped one-fetch auction batches. */ +export function createAuctionBatchService( + options: AuctionBatchServiceOptions +): AuctionBatchService { + const endpoint = options.endpoint ?? DEFAULT_AUCTION_ENDPOINT; + const parseResponse = options.parseResponse; + const scheduler = options.scheduler ?? defaultScheduler(); + const activeByNavigation = new Map>(); + const batches = new Set void }>>(); + let nextBatchOrdinal = 0; + let disposed = false; + + const activeSlots = (generation: object): Map => { + const existing = activeByNavigation.get(generation); + if (existing) return existing; + const created = new Map(); + activeByNavigation.set(generation, created); + return created; + }; + + const create = (input: AuctionBatchInput): AuctionBatch => { + const slots = frozen(Array.from(input.slots)); + const results: Array = new Array(slots.length); + let resolveResult: (value: Readonly) => void = () => undefined; + const result = new Promise>((resolve) => { + resolveResult = resolve; + }); + const immediate = (reason: RenderCancellationReason): AuctionBatch => { + const terminal = frozen({ + slots: frozen(slots.map((slot) => cancelledResult(slot, reason))), + }); + resolveResult(terminal); + return frozen({ result, cancel: () => undefined }); + }; + + if (disposed || !input.navigation.isCurrent()) return immediate('navigation_disposed'); + nextBatchOrdinal += 1; + const owner = input.navigation.createAuctionBatch(`auction-batch-${nextBatchOrdinal}`); + if (!owner) return immediate('navigation_disposed'); + + const children: Array = new Array(slots.length); + const navigationGeneration = input.navigation.generation; + const navigationSlots = activeSlots(navigationGeneration); + const controller = new AbortController(); + let callerListener: (() => void) | undefined; + let deadlineHandle: unknown; + let deadlineArmed = false; + let fetchPending = false; + let finished = false; + let building = true; + let remaining = slots.length; + + const clearDeadline = (): void => { + if (!deadlineArmed) return; + deadlineArmed = false; + const handle = deadlineHandle; + deadlineHandle = undefined; + try { + scheduler.clear(handle); + } catch { + // The logical deadline is already inert. + } + }; + + const abortFetch = (): void => { + if (!fetchPending) return; + fetchPending = false; + try { + controller.abort(); + } catch { + // Child outcomes remain authoritative if host abort throws. + } + }; + + const cleanupSignal = (): void => { + if (!callerListener || !input.signal) return; + try { + input.signal.removeEventListener('abort', callerListener); + } catch { + // A hostile signal cannot retain batch authority. + } + callerListener = undefined; + }; + + const finishIfComplete = (): void => { + if (finished || building || remaining !== 0) return; + finished = true; + clearDeadline(); + abortFetch(); + cleanupSignal(); + const membership = activeByNavigation.get(navigationGeneration); + if (membership?.size === 0) activeByNavigation.delete(navigationGeneration); + batches.delete(batchControl); + try { + owner.dispose(); + } catch { + // All public children are already terminal. + } + resolveResult( + frozen({ + slots: frozen( + results.map( + (entry, index) => entry ?? failedResult(slots[index] ?? '', 'internal_error') + ) + ), + }) + ); + }; + + const settleIndex = (index: number, terminal: AuctionBatchSlotResult): void => { + if (results[index]) return; + results[index] = terminal; + remaining -= 1; + const child = children[index]; + if (child) { + child.terminal = true; + if (navigationSlots.get(child.slot) === child) navigationSlots.delete(child.slot); + } + finishIfComplete(); + }; + + const cancelLive = (reason: RenderCancellationReason): void => { + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + if (!child || child.terminal) continue; + let cancelled: boolean; + try { + cancelled = child.attempt.cancel(reason) === true; + } catch { + cancelled = false; + } + if (!cancelled && !child.terminal) { + settleIndex(index, cancelledResult(child.slot, reason)); + } + } + finishIfComplete(); + }; + + const batchControl = frozen({ cancel: cancelLive }); + batches.add(batchControl); + + for (let index = 0; index < slots.length; index += 1) { + const slot = slots[index]; + if (!slot) { + settleIndex(index, failedResult('', 'internal_error')); + continue; + } + const previous = navigationSlots.get(slot); + if (previous && !previous.terminal) { + try { + previous.attempt.cancel('superseded'); + } catch { + // Exact removal below decides whether the new child may proceed. + } + } + if (navigationSlots.get(slot) === previous && previous && !previous.terminal) { + settleIndex(index, failedResult(slot, 'internal_error')); + continue; + } + + const issued = owner.createRenderAttempt(slot); + if (!issued.ok) { + settleIndex( + index, + issued.reason === 'identity_generation_failed' + ? failedResult(slot, 'identity_generation_failed') + : issued.reason === 'stale_owner' + ? cancelledResult(slot, 'navigation_disposed') + : failedResult(slot, 'internal_error') + ); + continue; + } + let created: RenderAttemptCreationResult; + try { + created = options.createAttempt(issued.value); + } catch { + created = frozen({ ok: false, reason: 'invalid_attempt' as const }); + } + if (!created.ok) { + try { + issued.value.dispose(); + } catch { + // The failed construction owns no public result authority. + } + settleIndex( + index, + created.reason === 'identity_generation_failed' + ? failedResult(slot, 'identity_generation_failed') + : created.reason === 'stale_owner' + ? cancelledResult(slot, 'navigation_disposed') + : failedResult(slot, 'internal_error') + ); + continue; + } + const child: BatchChild = { + attempt: created.value, + index, + navigationGeneration, + slot, + terminal: false, + }; + children[index] = child; + navigationSlots.set(slot, child); + let observing: boolean; + try { + observing = + created.value.onSettled((outcome) => + settleIndex(index, terminalResult(slot, outcome)) + ) === true; + } catch { + observing = false; + } + if (!observing && !child.terminal) { + try { + created.value.fail('internal_error'); + } catch { + settleIndex(index, failedResult(slot, 'internal_error')); + } + } + } + building = false; + + const publicBatch = frozen({ + result, + cancel: (): void => cancelLive('caller_aborted'), + }); + + if (remaining === 0) { + finishIfComplete(); + return publicBatch; + } + if (input.signal?.aborted === true) { + cancelLive('caller_aborted'); + return publicBatch; + } + if (input.signal) { + callerListener = (): void => cancelLive('caller_aborted'); + try { + input.signal.addEventListener('abort', callerListener, { once: true }); + } catch { + cancelLive('caller_aborted'); + return publicBatch; + } + if (Reflect.get(input.signal, 'aborted') === true) { + cancelLive('caller_aborted'); + return publicBatch; + } + } + + const failLive = (reason: RenderFailureReason): void => { + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + if (!child || child.terminal) continue; + try { + if (child.attempt.fail(reason) !== true && !child.terminal) { + settleIndex(index, failedResult(child.slot, reason)); + } + } catch { + settleIndex(index, failedResult(child.slot, reason)); + } + } + finishIfComplete(); + }; + + const completeTransport = (): void => { + fetchPending = false; + clearDeadline(); + }; + + const applyResponse = (parsed: ParsedAuctionBatchResponse): void => { + const bids = new Map(parsed.bids.map((bid) => [bid.candidateId, bid])); + const decisions = new Map( + parsed.auction.results.map((decision) => [decision.slot, decision]) + ); + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + if (!child || child.terminal) continue; + const decision = decisions.get(child.slot); + if (!decision) { + child.attempt.fail('invalid_response'); + continue; + } + if (decision.outcome === 'no_bid') { + child.attempt.noBid(); + continue; + } + if (decision.outcome === 'failed') { + child.attempt.fail(decision.reason); + continue; + } + const bid = bids.get(decision.candidateId); + const context: WinnerContext | undefined = bid + ? frozen({ selectedCpm: bid.price }) + : undefined; + let admitted: boolean; + try { + admitted = + !!bid && + !!context && + child.attempt.admitDirectWinner(bid.renderSource, context) === true; + } catch { + admitted = false; + } + if (!admitted || !bid) { + if (!child.terminal) child.attempt.fail('winner_not_renderable'); + continue; + } + let rendering: boolean; + try { + rendering = options.renderWinner(child.attempt, bid) === true; + } catch { + rendering = false; + } + if (!rendering && !child.terminal) child.attempt.fail('winner_not_renderable'); + } + }; + + const processFetch = async (fetchResult: Promise): Promise => { + let response: unknown; + try { + response = await fetchResult; + } catch { + if (finished) return; + completeTransport(); + failLive('network_error'); + return; + } + if (finished) return; + let ok: unknown; + let json: unknown; + try { + ok = Reflect.get(response as object, 'ok'); + json = Reflect.get(response as object, 'json'); + } catch { + completeTransport(); + failLive('invalid_response'); + return; + } + if (ok !== true) { + completeTransport(); + failLive('http_error'); + return; + } + if (typeof json !== 'function') { + completeTransport(); + failLive('invalid_response'); + return; + } + let body: unknown; + try { + body = await Reflect.apply(json, response, []); + } catch { + if (finished) return; + completeTransport(); + failLive('invalid_response'); + return; + } + if (finished) return; + let parsed: ParsedAuctionBatchResponse | undefined; + try { + parsed = parseResponse(body, options.cachePolicy); + } catch { + parsed = undefined; + } + completeTransport(); + if (!parsed || !responseMembershipIsExact(parsed, slots)) { + failLive('invalid_response'); + return; + } + applyResponse(parsed); + }; + + fetchPending = true; + try { + deadlineArmed = true; + const handle = scheduler.set(() => { + if (finished || !fetchPending) return; + abortFetch(); + clearDeadline(); + failLive('auction_timeout'); + }, input.timeoutMs); + if (deadlineArmed && !finished) deadlineHandle = handle; + else { + try { + scheduler.clear(handle); + } catch { + // A synchronously terminal batch cannot regain deadline authority. + } + } + } catch { + deadlineArmed = false; + fetchPending = false; + failLive('internal_error'); + return publicBatch; + } + if (finished) return publicBatch; + + let fetchResult: Promise; + try { + fetchResult = options.fetcher( + endpoint, + frozen({ + body: input.requestBody, + headers: frozen({ 'content-type': 'application/json' }), + method: 'POST', + signal: controller.signal, + }) + ); + } catch { + completeTransport(); + failLive('network_error'); + return publicBatch; + } + void processFetch(fetchResult); + return publicBatch; + }; + + return frozen({ + create, + dispose: (): void => { + if (disposed) return; + disposed = true; + const active = Array.from(batches); + batches.clear(); + for (let index = 0; index < active.length; index += 1) { + active[index]?.cancel('navigation_disposed'); + } + activeByNavigation.clear(); + }, + }); +} 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..189231439 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/auction_batch.test.ts @@ -0,0 +1,566 @@ +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('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(); + }); +}); From 9d47aa354a3184bfdaad456f3fb5d0137346fe8c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:42:10 -0700 Subject: [PATCH 063/194] Validate the hard-cutover request API --- .../lib/src/core/registry.ts | 456 +++++++++++++++++- .../trusted-server-js/lib/src/core/request.ts | 166 ++++++- .../trusted-server-js/lib/src/core/types.ts | 84 ++++ .../lib/src/kernel/fallback.ts | 372 +------------- .../lib/test/core/registry.test.ts | 137 ++++++ .../lib/test/core/request.test.ts | 76 +++ 6 files changed, 910 insertions(+), 381 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 06401a5e6..f2979166b 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -1,31 +1,461 @@ -// In-memory registry for ad units registered via tsjs (used by core + extensions). -import type { AdUnit, Size } from './types'; -import { toArray } from './util'; +// Programmatic ad-unit validation plus the legacy registry retained until Task 19. +import type { AdUnit, AddAdUnitsResult, ProgrammaticAdUnit, Size } from './types'; +import { validBoundedString } from './contracts/auction_projection'; import { log } from './log'; +import { toArray } from './util'; + +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(); + +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; + index: number; +} + +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; + for (const key of Object.getOwnPropertyNames(value)) { + 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 || + Object.getOwnPropertyNames(value).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 = Object.keys(record); + return actual.length === keys.length && actual.every((key) => keys.includes(key)); +} + +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 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 })) + : Object.keys(record!).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): Readonly> | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const rootSnapshot = snapshotJsonContainer(value); + if (!rootSnapshot || rootSnapshot.array) return undefined; + const root: Record = {}; + const active = new Set([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) { + 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 = 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 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; +} + +/** Exact JSON byte measurement that never consults `toJSON` or publisher prototypes. */ +function measureJsonBytes(value: unknown): number | undefined { + const primitive = primitiveJsonBytes(value); + if (primitive !== undefined) return primitive; + if (typeof value !== 'object' || value === null) return undefined; + const root = snapshotJsonContainer(value); + if (!root) return undefined; + const memo = new WeakMap(); + const active = new Set([value]); + const stack: JsonMeasureFrame[] = [ + { array: root.array, bytes: 2, entries: root.entries, index: 0, source: value }, + ]; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (!frame) return undefined; + 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 = boundedBytes(parent.bytes, frame.bytes); + if (parent.bytes > MAX_AUCTION_BODY_BYTES) return parent.bytes; + 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); + if (frame.bytes > MAX_AUCTION_BODY_BYTES) return frame.bytes; + const childPrimitive = primitiveJsonBytes(entry.value); + if (childPrimitive !== undefined) { + frame.bytes = boundedBytes(frame.bytes, childPrimitive); + if (frame.bytes > MAX_AUCTION_BODY_BYTES) return frame.bytes; + 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); + if (frame.bytes > MAX_AUCTION_BODY_BYTES) return frame.bytes; + 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, + }); + } + return undefined; +} + +function snapshotKnownSlots(knownSlots: ReadonlySet): ReadonlySet { + try { + return new Set(knownSlots); + } catch { + throw new AdUnitRegistrationError('slot_collision'); + } +} + +/** + * 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 prepared: ProgrammaticAdUnit[] = []; + 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 (const rawSize of rawSizes) { + const dimensions = ownDataArray(rawSize, 2); + if ( + !dimensions || + dimensions.length !== 2 || + dimensions.some( + (dimension) => + typeof dimension !== 'number' || + !Number.isFinite(dimension) || + !Number.isInteger(dimension) || + dimension <= 0 + ) + ) { + throw new AdUnitRegistrationError('invalid_dimensions', index); + } + if (dimensions.some((dimension) => (dimension as number) > 4_096)) { + throw new AdUnitRegistrationError('dimensions_out_of_range', index); + } + sizes.push(Object.freeze([dimensions[0] as number, dimensions[1] as number])); + } + + let bids: ProgrammaticAdUnit['bids']; + if (unit.bids !== undefined) { + const rawBids = ownDataArray(unit.bids, MAX_JSON_STRUCTURE_ENTRIES); + if (!rawBids) throw new AdUnitRegistrationError('invalid_bids', index); + const copiedBids: Array[number]> = []; + for (const rawBid of rawBids) { + 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 || + textEncoder.encode(bid.bidder).byteLength > 64 + ) { + throw new AdUnitRegistrationError('invalid_bidder', index); + } + let params: Readonly> | undefined; + if (bid.params !== undefined) { + params = copyJsonRecord(bid.params); + 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: Object.freeze({ + banner: Object.freeze({ sizes: Object.freeze(sizes) }), + }), + ...(bids === undefined ? {} : { bids }), + }) + ); + } + + const unitsBytes = measureJsonBytes(prepared); + if (unitsBytes === undefined) throw new AdUnitRegistrationError('invalid_params'); + // `{"adUnits":` + encoded array + `}`. + if (boundedBytes(12, unitsBytes) > MAX_AUCTION_BODY_BYTES) { + throw new AdUnitRegistrationError('request_body_too_large'); + } + if (occupied.size + prepared.length > MAX_ACTIVE_SLOT_RECORDS) { + throw new AdUnitRegistrationError('registry_capacity'); + } + return Object.freeze(prepared); +} + +export function addAdUnitsResult(units: readonly ProgrammaticAdUnit[]): AddAdUnitsResult { + return Object.freeze({ registered: Object.freeze(units.map(({ code }) => code)) }); +} -const registry = new Map(); +// The mutable merge registry remains connected only to the pre-cutover core entry. +const legacyRegistry = new Map(); -// 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 }); + for (const unit of toArray(units)) { + if (!unit?.code) continue; + legacyRegistry.set(unit.code, { ...legacyRegistry.get(unit.code), ...unit }); } log.info('addAdUnits:', { count: toArray(units).length }); } -// 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; } -// Return a snapshot array of all registered ad units. export function getAllUnits(): AdUnit[] { - return Array.from(registry.values()); + return Array.from(legacyRegistry.values()); } -// Look up a unit by its code. export function getUnit(code: string): AdUnit | undefined { - return registry.get(code); + return legacyRegistry.get(code); } diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index 6c41ea498..d0ba05ab8 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -8,8 +8,166 @@ import { getAllUnits, firstSize } from './registry'; import { createAdIframe, findSlot, buildCreativeDocument, sanitizeCreativeHtml } from './render'; import { isEffectivelyVisible, recordRender, stampCreativeTrace } from './trace'; +const REQUEST_ADS_DEFAULT_TIMEOUT_MS = 10_000; +const REQUEST_ADS_MAX_SLOTS = 256; +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; + for (const key of Object.getOwnPropertyNames(value)) { + 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 || + Object.getOwnPropertyNames(value).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' + ? (Reflect.apply(abortSignalAbortedGetter, signal, []) as boolean) + : undefined; + } catch { + return undefined; + } +} + +/** 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 || + !Object.keys(options).every((key) => key === 'slots' || key === 'timeoutMs' || key === 'signal') + ) { + 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 (const slot of rawSlots) { + if ( + typeof slot !== 'string' || + slot.length === 0 || + new TextEncoder().encode(slot).byteLength > 256 || + /[\p{Cc}]/u.test(slot) || + /[\uD800-\uDFFF]/u.test(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 }); +} + export type RequestAdsCallback = () => void; -export interface RequestAdsOptions { +export interface LegacyRequestAdsOptions { bidsBackHandler?: RequestAdsCallback | undefined; timeout?: number | undefined; } @@ -29,14 +187,14 @@ type RenderCreativeInlineOptions = { // Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. export function requestAds( - callbackOrOpts?: RequestAdsCallback | RequestAdsOptions, - _maybeOpts?: RequestAdsOptions + callbackOrOpts?: RequestAdsCallback | LegacyRequestAdsOptions, + _maybeOpts?: LegacyRequestAdsOptions ): void { let callback: RequestAdsCallback | undefined; if (typeof callbackOrOpts === 'function') { callback = callbackOrOpts as RequestAdsCallback; } else { - callback = (callbackOrOpts as RequestAdsOptions | undefined)?.bidsBackHandler; + callback = (callbackOrOpts as LegacyRequestAdsOptions | undefined)?.bidsBackHandler; } log.info('requestAds: called', { hasCallback: typeof callback === 'function' }); diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index f10300e18..9335dda00 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -330,6 +330,90 @@ export interface BootManifestV1 { 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 interface TsjsApi { version: string; que: Array<() => void>; diff --git a/crates/trusted-server-js/lib/src/kernel/fallback.ts b/crates/trusted-server-js/lib/src/kernel/fallback.ts index b61d44b39..7276ba00d 100644 --- a/crates/trusted-server-js/lib/src/kernel/fallback.ts +++ b/crates/trusted-server-js/lib/src/kernel/fallback.ts @@ -1,65 +1,21 @@ import { parseCacheFetchPolicyV1 } from '../core/config'; -import { - parseBrowserAuctionProjectionV1, - validBoundedString, -} from '../core/contracts/auction_projection'; +import { parseBrowserAuctionProjectionV1 } from '../core/contracts/auction_projection'; import { log } from '../core/log'; +import { prepareProgrammaticAdUnits } from '../core/registry'; +import { validateRequestAdsOptions } from '../core/request'; import type { BootManifestV1 } from '../core/types'; +export { AdUnitRegistrationError, type AdUnitRegistrationErrorCode } from '../core/registry'; +export { RequestAdsInputError, type RequestAdsInputErrorCode } from '../core/request'; + import type { BootFailureReason } from './integration_registry'; -const textEncoder = new TextEncoder(); -const MAX_AUCTION_BODY_BYTES = 256 * 1024; -const MAX_JSON_ARRAY_ITEMS = Math.floor((MAX_AUCTION_BODY_BYTES - 1) / 2); const SAFE_PROJECTION = { version: 1, auction: { version: 1, auctionId: 'fallback', results: [] }, bids: [], } as const; -export class RequestAdsInputError extends Error { - public readonly code: - | 'invalid_options' - | 'invalid_slots' - | 'empty_slots' - | 'duplicate_slot' - | 'invalid_timeout' - | 'invalid_signal'; - - public constructor(code: RequestAdsInputError['code']) { - super(code); - this.name = 'RequestAdsInputError'; - this.code = code; - } -} - -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; - } -} - export class TsjsUnavailableError extends Error { public readonly code = 'runtime_unavailable' as const; public readonly releaseId: string; @@ -248,318 +204,6 @@ export function buildFallbackBoot(releaseId: string, candidate: unknown): Readon }); } -function validSlotId(value: unknown): value is string { - return validBoundedString(value, 256); -} - -function readAborted(signal: unknown): boolean | undefined { - try { - const getter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; - return getter?.call(signal) as boolean | undefined; - } catch { - return undefined; - } -} - -function validateRequestOptions(value: unknown): { - readonly slots: readonly string[] | undefined; - readonly aborted: boolean; -} { - if (value === undefined) return { slots: undefined, aborted: false }; - const options = ownDataRecord(value); - if ( - !options || - !Object.keys(options).every((key) => ['slots', 'timeoutMs', 'signal'].includes(key)) - ) { - throw new RequestAdsInputError('invalid_options'); - } - let slots: readonly string[] | undefined; - if (Object.prototype.hasOwnProperty.call(options, 'slots')) { - const candidateSlots = snapshotOwnArray(options.slots, 256); - if (!candidateSlots) { - throw new RequestAdsInputError('invalid_slots'); - } - if (candidateSlots.length === 0) throw new RequestAdsInputError('empty_slots'); - const seen = new Set(); - const copy: string[] = []; - for (const slot of candidateSlots) { - if (!validSlotId(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); - } - if ( - Object.prototype.hasOwnProperty.call(options, 'timeoutMs') && - (!Number.isInteger(options.timeoutMs) || - (options.timeoutMs as number) < 100 || - (options.timeoutMs as number) > 30_000) - ) { - throw new RequestAdsInputError('invalid_timeout'); - } - let aborted = false; - if (Object.prototype.hasOwnProperty.call(options, 'signal')) { - const candidate = readAborted(options.signal); - if (candidate === undefined) throw new RequestAdsInputError('invalid_signal'); - aborted = candidate; - } - return { slots, aborted }; -} - -interface JsonMeasurement { - readonly bytes: number; -} - -interface JsonMeasurementContext { - readonly memo: WeakMap; - readonly snapshots: WeakMap; -} - -interface JsonNode { - readonly entries: readonly JsonEntry[]; -} - -interface JsonEntry { - readonly prefixBytes: number; - readonly value: unknown; -} - -interface JsonFrame { - readonly object: object; - readonly node: JsonNode; - bytes: number; - index: number; -} - -const JSON_TOO_LARGE = Symbol('json_too_large'); -const TOO_LARGE_MEASUREMENT = Object.freeze({ bytes: MAX_AUCTION_BODY_BYTES + 1 }); - -function boundedByteSum(left: number, right: number): number { - return Math.min(MAX_AUCTION_BODY_BYTES + 1, left + right); -} - -function primitiveJsonBytes(value: unknown): number | undefined { - if (value === null) return 4; - if (typeof value === 'boolean') return value ? 4 : 5; - if (typeof value === 'string') return textEncoder.encode(JSON.stringify(value)).length; - if (typeof value === 'number' && Number.isFinite(value)) return String(value).length; - return undefined; -} - -function snapshotJsonNode( - value: unknown, - context: JsonMeasurementContext, - recordSnapshot?: Record -): JsonNode | typeof JSON_TOO_LARGE | undefined { - if (typeof value !== 'object' || value === null) return undefined; - if (context.snapshots.has(value)) { - return context.snapshots.get(value) ?? undefined; - } - let node: JsonNode | typeof JSON_TOO_LARGE | undefined; - try { - let entries: JsonEntry[]; - if (recordSnapshot) { - entries = Object.keys(recordSnapshot).map((key, index) => ({ - prefixBytes: (index === 0 ? 0 : 1) + textEncoder.encode(JSON.stringify(key)).length + 1, - value: recordSnapshot[key], - })); - } else if (Array.isArray(value)) { - if (value.length > MAX_JSON_ARRAY_ITEMS) { - node = JSON_TOO_LARGE; - return node; - } - const values = snapshotOwnArray(value, MAX_JSON_ARRAY_ITEMS); - if (!values) return undefined; - entries = values.map((entry, index) => ({ - prefixBytes: index === 0 ? 0 : 1, - value: entry, - })); - } else { - const record = ownDataRecord(value); - if (!record) return undefined; - entries = Object.keys(record).map((key, index) => ({ - prefixBytes: (index === 0 ? 0 : 1) + textEncoder.encode(JSON.stringify(key)).length + 1, - value: record[key], - })); - } - node = Object.freeze({ entries: Object.freeze(entries) }); - return node; - } catch { - return undefined; - } finally { - context.snapshots.set(value, node ?? null); - } -} - -function measureJsonData( - value: unknown, - context: JsonMeasurementContext, - recordSnapshot?: Record -): JsonMeasurement | undefined { - const primitiveBytes = primitiveJsonBytes(value); - if (primitiveBytes !== undefined) return { bytes: primitiveBytes }; - if (typeof value !== 'object' || value === null) return undefined; - const cached = context.memo.get(value); - if (cached) return cached; - const root = snapshotJsonNode(value, context, recordSnapshot); - if (root === JSON_TOO_LARGE) return TOO_LARGE_MEASUREMENT; - if (!root) return undefined; - - const active = new Set([value]); - const stack: JsonFrame[] = [{ object: value, node: root, bytes: 2, index: 0 }]; - while (stack.length > 0) { - const frame = stack[stack.length - 1]; - if (!frame) return undefined; - if (frame.index >= frame.node.entries.length) { - const measurement = Object.freeze({ bytes: frame.bytes }); - context.memo.set(frame.object, measurement); - active.delete(frame.object); - stack.pop(); - const parent = stack[stack.length - 1]; - if (!parent) return measurement; - parent.bytes = boundedByteSum(parent.bytes, measurement.bytes); - if (parent.bytes > MAX_AUCTION_BODY_BYTES) return TOO_LARGE_MEASUREMENT; - continue; - } - - const entry = frame.node.entries[frame.index]; - frame.index += 1; - if (!entry) return undefined; - frame.bytes = boundedByteSum(frame.bytes, entry.prefixBytes); - if (frame.bytes > MAX_AUCTION_BODY_BYTES) return TOO_LARGE_MEASUREMENT; - const childBytes = primitiveJsonBytes(entry.value); - if (childBytes !== undefined) { - frame.bytes = boundedByteSum(frame.bytes, childBytes); - if (frame.bytes > MAX_AUCTION_BODY_BYTES) return TOO_LARGE_MEASUREMENT; - continue; - } - if (typeof entry.value !== 'object' || entry.value === null || active.has(entry.value)) { - return undefined; - } - const childMeasurement = context.memo.get(entry.value); - if (childMeasurement) { - frame.bytes = boundedByteSum(frame.bytes, childMeasurement.bytes); - if (frame.bytes > MAX_AUCTION_BODY_BYTES) return TOO_LARGE_MEASUREMENT; - continue; - } - const childNode = snapshotJsonNode(entry.value, context); - if (childNode === JSON_TOO_LARGE) return TOO_LARGE_MEASUREMENT; - if (!childNode) return undefined; - active.add(entry.value); - stack.push({ object: entry.value, node: childNode, bytes: 2, index: 0 }); - } - return undefined; -} - -function measureJsonRecord( - value: unknown, - context: JsonMeasurementContext -): JsonMeasurement | undefined { - try { - if (Array.isArray(value)) return undefined; - } catch { - return undefined; - } - const record = ownDataRecord(value); - return record ? measureJsonData(value, context, record) : undefined; -} - -function validateProgrammaticUnits(value: unknown, knownSlots: ReadonlySet): void { - let units: readonly unknown[] | undefined; - try { - units = Array.isArray(value) ? snapshotOwnArray(value, 256) : [value]; - } catch { - throw new AdUnitRegistrationError('invalid_units'); - } - if (!units) throw new AdUnitRegistrationError('invalid_units'); - if (units.length === 0 || units.length > 256) throw new AdUnitRegistrationError('invalid_units'); - const seen = new Set(); - const measurementContext: JsonMeasurementContext = { - memo: new WeakMap(), - snapshots: 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 (!validSlotId(unit.code)) throw new AdUnitRegistrationError('invalid_code', index); - if (seen.has(unit.code)) throw new AdUnitRegistrationError('duplicate_code', index); - if (knownSlots.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 sizes = snapshotOwnArray(banner.sizes, MAX_JSON_ARRAY_ITEMS); - if (!sizes || sizes.length === 0) { - throw new AdUnitRegistrationError('invalid_media_types', index); - } - for (const size of sizes) { - const dimensions = snapshotOwnArray(size, 2); - if ( - !dimensions || - dimensions.length !== 2 || - dimensions.some( - (dimension) => - typeof dimension !== 'number' || - !Number.isFinite(dimension) || - !Number.isInteger(dimension) || - dimension <= 0 - ) - ) { - throw new AdUnitRegistrationError('invalid_dimensions', index); - } - if (dimensions.some((dimension) => (dimension as number) > 4096)) { - throw new AdUnitRegistrationError('dimensions_out_of_range', index); - } - } - if (unit.bids !== undefined) { - const bids = snapshotOwnArray(unit.bids, MAX_JSON_ARRAY_ITEMS); - if (!bids) throw new AdUnitRegistrationError('invalid_bids', index); - for (const rawBid of bids) { - const bid = ownDataRecord(rawBid); - if (!bid || (!exactKeys(bid, ['bidder']) && !exactKeys(bid, ['bidder', 'params']))) { - throw new AdUnitRegistrationError('invalid_bids', index); - } - if ( - typeof bid.bidder !== 'string' || - textEncoder.encode(bid.bidder).length > 64 || - bid.bidder.length === 0 - ) { - throw new AdUnitRegistrationError('invalid_bidder', index); - } - if (bid.params !== undefined) { - const measured = measureJsonRecord(bid.params, measurementContext); - if (!measured) { - throw new AdUnitRegistrationError('invalid_params', index); - } - } - } - } - } - const measured = measureJsonData(units, measurementContext); - if (!measured) { - throw new AdUnitRegistrationError('invalid_params'); - } - if (measured.bytes > MAX_AUCTION_BODY_BYTES) { - throw new AdUnitRegistrationError('request_body_too_large'); - } - if (knownSlots.size + units.length > 256) { - throw new AdUnitRegistrationError('registry_capacity'); - } -} - const LOG_LEVELS = Object.freeze({ silent: true, error: true, @@ -620,14 +264,14 @@ export function createFallbackFields( addAdUnits: { enumerable: true, value: (units: unknown) => { - validateProgrammaticUnits(units, known); + prepareProgrammaticAdUnits(units, known); throw new TsjsUnavailableError(options.releaseId, options.reason); }, }, requestAds: { enumerable: true, value: async (requestOptions?: unknown) => { - const validated = validateRequestOptions(requestOptions); + const validated = validateRequestAdsOptions(requestOptions); const selected = validated.slots ?? knownSlots; return deepFreeze({ slots: selected.map((slot) => 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 51b0e3a84..cc0fe6283 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -1,6 +1,29 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { AdUnit } from '../../src/core/types'; +import { AdUnitRegistrationError, prepareProgrammaticAdUnits } 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 () => { @@ -26,4 +49,118 @@ describe('registry', () => { expect(all.length).toBe(1); expect(firstSize(all[0]!)!.join('x')).toBe('320x50'); }); + + 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); + } + const existing = new Set(Array.from({ length: 256 }, (_, index) => `server-${index}`)); + expectRegistrationError( + () => prepareProgrammaticAdUnits(unit('overflow'), existing), + 'registry_capacity' + ); + }); }); 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 2fc652053..26db64b26 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -2,16 +2,92 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import envelope from '../fixtures/aps-renderer-v1.json'; import type { addAdUnits } from '../../src/core/registry'; +import { RequestAdsInputError, validateRequestAdsOptions } from '../../src/core/request'; /** Test view of the global scope with a mockable `fetch`. */ const testGlobal = globalThis as unknown as { fetch: ReturnType }; type AddAdUnitsArg = Parameters[0]; +function expectInputError(callback: () => unknown, code: RequestAdsInputError['code']): void { + try { + callback(); + throw new Error('should reject request options'); + } catch (error) { + expect(error).toBeInstanceOf(RequestAdsInputError); + expect(error).toMatchObject({ code }); + } +} + async function flushRequestAds(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } +describe('requestAds input contract', () => { + it('accepts omitted options and snapshots ordered slots with the exact default timeout', () => { + expect(validateRequestAdsOptions(undefined)).toEqual({ + aborted: false, + signal: undefined, + slots: undefined, + timeoutMs: 10_000, + }); + const slots = ['server-slot', 'programmatic-slot']; + const validated = validateRequestAdsOptions({ slots, timeoutMs: 100 }); + slots.reverse(); + expect(validated).toEqual({ + aborted: false, + signal: undefined, + slots: ['server-slot', 'programmatic-slot'], + timeoutMs: 100, + }); + expect(Object.isFrozen(validated)).toBe(true); + expect(Object.isFrozen(validated.slots)).toBe(true); + }); + + it.each([null, [], new Date(), { unknown: true }])( + 'rejects non-exact options %#', + (candidate) => { + expectInputError(() => validateRequestAdsOptions(candidate), 'invalid_options'); + } + ); + + it('rejects accessors without invoking them', () => { + const getter = vi.fn(() => ['slot']); + const options = {}; + Object.defineProperty(options, 'slots', { enumerable: true, get: getter }); + expectInputError(() => validateRequestAdsOptions(options), 'invalid_options'); + expect(getter).not.toHaveBeenCalled(); + }); + + it.each([ + [{ slots: 'slot' }, 'invalid_slots'], + [{ slots: [] }, 'empty_slots'], + [{ slots: ['slot', 'slot'] }, 'duplicate_slot'], + [{ slots: [''] }, 'invalid_slots'], + [{ slots: ['x'.repeat(257)] }, 'invalid_slots'], + [{ slots: Array.from({ length: 257 }, (_, index) => `slot-${index}`) }, 'invalid_slots'], + [{ timeoutMs: 99 }, 'invalid_timeout'], + [{ timeoutMs: 30_001 }, 'invalid_timeout'], + [{ timeoutMs: 100.5 }, 'invalid_timeout'], + [{ signal: { aborted: false } }, 'invalid_signal'], + ] as const)('rejects request boundary %#', (candidate, code) => { + expectInputError(() => validateRequestAdsOptions(candidate), code); + }); + + it('accepts exact timeout and AbortSignal boundaries through the platform brand getter', () => { + const controller = new AbortController(); + expect( + validateRequestAdsOptions({ timeoutMs: 30_000, signal: controller.signal }) + ).toMatchObject({ aborted: false, signal: controller.signal, timeoutMs: 30_000 }); + controller.abort(); + expect(validateRequestAdsOptions({ timeoutMs: 100, signal: controller.signal })).toMatchObject({ + aborted: true, + signal: controller.signal, + timeoutMs: 100, + }); + }); +}); + describe('request.requestAds', () => { let originalFetch: typeof globalThis.fetch; From c6de718e51115727cb3f71690d9a00ab2ae7324e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:43:05 -0700 Subject: [PATCH 064/194] Harden auction settlement and slot snapshots --- .../lib/src/services/auction_batch.ts | 66 +++++++++++-------- .../lib/src/services/slots.ts | 23 +++++++ .../lib/test/services/auction_batch.test.ts | 31 +++++++++ .../lib/test/services/slots.test.ts | 40 +++++++++++ 4 files changed, 134 insertions(+), 26 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/auction_batch.ts b/crates/trusted-server-js/lib/src/services/auction_batch.ts index 91ce304a9..24dee8c92 100644 --- a/crates/trusted-server-js/lib/src/services/auction_batch.ts +++ b/crates/trusted-server-js/lib/src/services/auction_batch.ts @@ -252,19 +252,43 @@ export function createAuctionBatchService( finishIfComplete(); }; + const containCancellation = (child: BatchChild, reason: RenderCancellationReason): void => { + try { + child.attempt.cancel(reason); + } catch { + // Synthetic public settlement below contains a broken attempt implementation. + } + if (!child.terminal) settleIndex(child.index, cancelledResult(child.slot, reason)); + }; + + const containFailure = (child: BatchChild, reason: RenderFailureReason): void => { + try { + child.attempt.fail(reason); + } catch { + // Synthetic public settlement below contains a broken attempt implementation. + } + if (!child.terminal) settleIndex(child.index, failedResult(child.slot, reason)); + }; + + const containNoBid = (child: BatchChild): void => { + try { + child.attempt.noBid(); + } catch { + // Synthetic public settlement below contains a broken attempt implementation. + } + if (!child.terminal) { + settleIndex( + child.index, + terminalResult(child.slot, frozen({ outcome: 'no_bid' as const })) + ); + } + }; + const cancelLive = (reason: RenderCancellationReason): void => { for (let index = 0; index < children.length; index += 1) { const child = children[index]; if (!child || child.terminal) continue; - let cancelled: boolean; - try { - cancelled = child.attempt.cancel(reason) === true; - } catch { - cancelled = false; - } - if (!cancelled && !child.terminal) { - settleIndex(index, cancelledResult(child.slot, reason)); - } + containCancellation(child, reason); } finishIfComplete(); }; @@ -344,11 +368,7 @@ export function createAuctionBatchService( observing = false; } if (!observing && !child.terminal) { - try { - created.value.fail('internal_error'); - } catch { - settleIndex(index, failedResult(slot, 'internal_error')); - } + containFailure(child, 'internal_error'); } } building = false; @@ -384,13 +404,7 @@ export function createAuctionBatchService( for (let index = 0; index < children.length; index += 1) { const child = children[index]; if (!child || child.terminal) continue; - try { - if (child.attempt.fail(reason) !== true && !child.terminal) { - settleIndex(index, failedResult(child.slot, reason)); - } - } catch { - settleIndex(index, failedResult(child.slot, reason)); - } + containFailure(child, reason); } finishIfComplete(); }; @@ -410,15 +424,15 @@ export function createAuctionBatchService( if (!child || child.terminal) continue; const decision = decisions.get(child.slot); if (!decision) { - child.attempt.fail('invalid_response'); + containFailure(child, 'invalid_response'); continue; } if (decision.outcome === 'no_bid') { - child.attempt.noBid(); + containNoBid(child); continue; } if (decision.outcome === 'failed') { - child.attempt.fail(decision.reason); + containFailure(child, decision.reason); continue; } const bid = bids.get(decision.candidateId); @@ -435,7 +449,7 @@ export function createAuctionBatchService( admitted = false; } if (!admitted || !bid) { - if (!child.terminal) child.attempt.fail('winner_not_renderable'); + if (!child.terminal) containFailure(child, 'winner_not_renderable'); continue; } let rendering: boolean; @@ -444,7 +458,7 @@ export function createAuctionBatchService( } catch { rendering = false; } - if (!rendering && !child.terminal) child.attempt.fail('winner_not_renderable'); + if (!rendering && !child.terminal) containFailure(child, 'winner_not_renderable'); } }; diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 12f9d2296..53fcfde7c 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -32,11 +32,14 @@ export interface SlotRegistration { readonly source: SlotSource; readonly adUnitCode?: string; readonly domAliases?: readonly string[]; + /** Detached programmatic `/auction` unit; absent for server projection slots. */ + readonly directAuctionUnit?: Readonly; } /** Public immutable view of a registered slot. */ export interface SlotRecord { readonly adUnitCode: string | undefined; + readonly directAuctionUnit?: Readonly; readonly domAliases: readonly string[]; readonly navigationGeneration: object; readonly ordinal: number; @@ -138,6 +141,7 @@ export interface SlotService { ) => SlotRegistrationResult; readonly request: (input: SlotRequestInput) => SlotRequestHandle; readonly requestBatch: (inputs: readonly SlotBatchRequestInput[]) => readonly SlotRequestHandle[]; + readonly snapshotRegisteredSlots: (owner: NavigationSession) => readonly SlotRecord[] | undefined; readonly resolveAdUnitCode: (adUnitCode: string) => SlotRecord | undefined; readonly resolveDomAlias: (alias: string) => SlotRecord | undefined; readonly resolveRegisteredSlot: (registeredSlotId: string) => SlotRecord | undefined; @@ -1226,6 +1230,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const prepared: Array<{ readonly adUnitCode: string | undefined; readonly aliases: readonly string[]; + readonly directAuctionUnit: Readonly | undefined; readonly id: string; readonly placementKeys: readonly string[]; readonly source: SlotSource; @@ -1238,6 +1243,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const id = registration.registeredSlotId; const source = registration.source; const adUnitCode = registration.adUnitCode; + const directAuctionUnit = registration.directAuctionUnit; const aliases = frozenAliases(registration.domAliases); if ( typeof id !== 'string' || @@ -1245,6 +1251,11 @@ export function createSlotService(options: SlotServiceOptions): SlotService { (source !== 'server' && source !== 'programmatic') || (adUnitCode !== undefined && (typeof adUnitCode !== 'string' || !validSlotIdentity(adUnitCode))) || + (directAuctionUnit !== undefined && + (source !== 'programmatic' || + typeof directAuctionUnit !== 'object' || + directAuctionUnit === null || + !Object.isFrozen(directAuctionUnit))) || aliases === undefined ) { return Object.freeze({ ok: false, reason: 'invalid_slot_id' }); @@ -1260,6 +1271,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { prepared[prepared.length] = { adUnitCode, aliases, + directAuctionUnit, id, placementKeys: registrationPlacementKeys, source, @@ -1285,6 +1297,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const ordinal = state.nextOrdinal + index; const view: SlotRecord = Object.freeze({ adUnitCode: registration.adUnitCode, + ...(registration.directAuctionUnit === undefined + ? {} + : { directAuctionUnit: registration.directAuctionUnit }), domAliases: registration.aliases, navigationGeneration: owner.generation, ordinal, @@ -1972,6 +1987,14 @@ export function createSlotService(options: SlotServiceOptions): SlotService { register, request, requestBatch, + snapshotRegisteredSlots: (owner: NavigationSession): readonly SlotRecord[] | undefined => { + if (!owner.isCurrent()) return undefined; + const state = mapValue(navigationStates, owner.generation); + if (!state || state.disposed || state.owner !== owner) return undefined; + const records = mapValueSnapshot(state.records); + records.sort((left, right) => left.view.ordinal - right.view.ordinal); + return Object.freeze(records.map(({ view }) => view)); + }, resolveAdUnitCode: (adUnitCode: string) => resolveUnique(adUnitCodes, adUnitCode), resolveDomAlias: (alias: string) => resolveUnique(domAliases, alias), resolveRegisteredSlot: (registeredSlotId: string) => 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 index 189231439..617a10e2d 100644 --- a/crates/trusted-server-js/lib/test/services/auction_batch.test.ts +++ b/crates/trusted-server-js/lib/test/services/auction_batch.test.ts @@ -563,4 +563,35 @@ describe('auction batch service', () => { }); 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); + }); }); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 7d02acf3c..7c507c50b 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -254,6 +254,46 @@ describe('slot registry', () => { 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(); From 9b766c39c0c0f3cc3523415c8f7e0d8fd898e949 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:51:27 -0700 Subject: [PATCH 065/194] Wire the test-only direct auction API --- .../lib/src/composition/browser.ts | 239 +++++++++++++++++- .../lib/src/core/contracts/request_ads.ts | 165 ++++++++++++ .../trusted-server-js/lib/src/core/request.ts | 163 +----------- .../lib/src/kernel/fallback.ts | 4 +- .../lib/test/composition/browser.test.ts | 148 +++++++++++ .../lib/test/core/request.test.ts | 7 + 6 files changed, 566 insertions(+), 160 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/core/contracts/request_ads.ts diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 6f97ff093..89fe7ac98 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -18,11 +18,18 @@ import { type PrebidGlobalTarget, } from '../adapters/prebid'; import { parseCacheFetchPolicyV1 } from '../core/config'; +import { parseTrustedServerAuctionResponseV1 } from '../core/auction'; import { parseBidRenderSourceV1, parseBrowserAuctionProjectionV1, } from '../core/contracts/auction_projection'; import { validateApsRenderer } from '../core/contracts/aps_renderer'; +import { + AdUnitRegistrationError, + addAdUnitsResult, + prepareProgrammaticAdUnits, +} from '../core/registry'; +import { validateRequestAdsOptions } from '../core/request'; import { prepareAdmIframe } from '../core/render'; import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; @@ -31,6 +38,11 @@ import { createRuntimeSession } from '../kernel/sessions'; import type { CoreActivationContext } from '../kernel/integration_registry'; import { createRuntime, type Runtime, type RuntimeOptions } from '../kernel/runtime'; import { createAuctionContextRegistry, type AuctionContextRegistry } from '../services/context'; +import { + createAuctionBatchService, + type AuctionBatchFetcher, + type AuctionBatchService, +} from '../services/auction_batch'; import { createPageBidsController, type PageBidsController, @@ -38,15 +50,18 @@ import { } from '../services/projections'; import { createReservationService, type ReservationService } from '../services/reservations'; import { + createCommittedArtifactStore, + createRenderAttempt, createRendererNonceRegistry, resolveCacheAdmAttempt, renderDirectCacheAttempt, renderDirectAdmAttempt, type RenderAttempt, + type CommittedArtifactStore, type RendererNonceRegistry, } from '../services/render'; import { createPucBridge, type PucBridge, type PucBridgeOptions } from '../services/puc_bridge'; -import { createSlotService, type SlotService } from '../services/slots'; +import { createSlotService, type SlotRecord, type SlotService } from '../services/slots'; import { createTargetingService, type TargetingService } from '../services/targeting'; export interface BrowserAdapters { @@ -60,6 +75,8 @@ export interface BrowserComposition { } export interface BrowserServices { + readonly artifacts: CommittedArtifactStore; + readonly auctionBatches: AuctionBatchService; readonly pucBridge: PucBridge; readonly reservations: ReservationService; readonly rendererNonces: RendererNonceRegistry; @@ -109,6 +126,7 @@ export interface BrowserCoreActivations { } export interface TestBrowserRuntimeCompositionOptions extends BrowserCompositionOptions { + readonly auctionFetcherForTest?: AuctionBatchFetcher; readonly coreActivations: BrowserCoreActivations; readonly createIdentityIssuerForTest?: NavigationIdentityIssuerFactory; readonly admittedProgrammaticSlotsForTest?: readonly string[]; @@ -208,9 +226,177 @@ export function createTestBrowserRuntimeComposition( let preparedBrowserServices: PreparedBrowserServices | undefined; let browserServices: Readonly | undefined; let auctionContextRegistry: AuctionContextRegistry | undefined; + let auctionBatchService: AuctionBatchService | undefined; let projectionParser: ((candidate: unknown) => object | undefined) | undefined; + 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 addProgrammaticAdUnits = (candidate: unknown): unknown => { + const navigation = runtimeSession?.currentNavigation; + const slots = browserServices?.slots; + const snapshot = navigation && slots?.snapshotRegisteredSlots(navigation); + if (!navigation || !slots || !snapshot) throw new Error('TSJS navigation is unavailable'); + const knownSlots = new Set(snapshot.map(({ registeredSlotId }) => registeredSlotId)); + const prepared = prepareProgrammaticAdUnits(candidate, knownSlots); + const registered = slots.register( + navigation, + prepared.map((unit) => ({ + directAuctionUnit: unit, + registeredSlotId: unit.code, + source: 'programmatic' as const, + })) + ); + if (!registered.ok) { + if (registered.reason === 'registry_capacity') { + throw new AdUnitRegistrationError('registry_capacity'); + } + if (registered.reason === 'duplicate_slot') { + throw new AdUnitRegistrationError('slot_collision'); + } + throw new Error('TSJS navigation changed during registration'); + } + 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 { + requestBody = JSON.stringify({ adUnits, config: context }); + if (new TextEncoder().encode(requestBody).byteLength > 256 * 1024) { + throw new Error('auction request body exceeds limit'); + } + } 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 runtime = createRuntime({ ...runtimeOptions, + kernel: { + addAdUnits: addProgrammaticAdUnits, + diagnostics: runtimeOptions.kernel.diagnostics, + requestAds: requestDirectAds, + }, activateOwner: (context) => { const boot = context.boot as unknown as AcceptedBrowserBoot; const cachePolicy = @@ -227,6 +413,7 @@ export function createTestBrowserRuntimeComposition( const reservationService = createReservationService({ prepareRenderSource: (candidate) => parseBidRenderSourceV1(candidate, cachePolicy), }); + const artifacts = createCommittedArtifactStore(); const rendererNonces = createRendererNonceRegistry(); const publisherOrigin = window.location.origin; const fetchCache = globalThis.fetch; @@ -318,7 +505,53 @@ export function createTestBrowserRuntimeComposition( return false; } }; + const resolveDirectContainer = (record: SlotRecord): HTMLElement | undefined => { + try { + if (typeof document === 'undefined' || record.domAliases.length === 0) return undefined; + const aliases = new Set(record.domAliases); + 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 && aliases.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 batchCoordinator = createAuctionBatchService({ + ...(cachePolicy ? { cachePolicy } : {}), + createAttempt: (owner) => + createRenderAttempt({ + artifacts, + owner, + prepareRenderSource: (candidate) => parseBidRenderSourceV1(candidate, cachePolicy), + reservations: reservationService, + }), + fetcher: (input, init) => { + if (typeof fetchAuction !== 'function') return Promise.reject(new Error('unavailable')); + return fetchAuction(input, init); + }, + parseResponse: parseTrustedServerAuctionResponseV1, + renderWinner: (attempt) => { + 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 services = Object.freeze({ + artifacts, + auctionBatches: batchCoordinator, reservations: reservationService, rendererNonces, renderDirectAdm, @@ -339,7 +572,9 @@ export function createTestBrowserRuntimeComposition( interfaces: Object.freeze({ adapters: composition.adapters, ...services }), }); context.onDispose(() => { + batchCoordinator.dispose(); session.dispose(); + artifacts.dispose(); reservationService.dispose(); rendererNonces.dispose(); slotService.dispose(); @@ -350,6 +585,7 @@ export function createTestBrowserRuntimeComposition( runtimeSession = undefined; preparedBrowserServices = undefined; browserServices = undefined; + auctionBatchService = undefined; auctionContextRegistry = undefined; projectionParser = undefined; } @@ -375,6 +611,7 @@ export function createTestBrowserRuntimeComposition( runtimeOwner: session, }); runtimeSession = session; + auctionBatchService = batchCoordinator; auctionContextRegistry = contextRegistry; projectionParser = parseProjection; return runtimeOptions.activateOwner?.(context); 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..59b751b33 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts @@ -0,0 +1,165 @@ +const REQUEST_ADS_DEFAULT_TIMEOUT_MS = 10_000; +const REQUEST_ADS_MAX_SLOTS = 256; +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; + for (const key of Object.getOwnPropertyNames(value)) { + 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 || + Object.getOwnPropertyNames(value).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' + ? (Reflect.apply(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 || + !Object.keys(options).every((key) => key === 'slots' || key === 'timeoutMs' || key === 'signal') + ) { + 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 (const slot of rawSlots) { + if ( + typeof slot !== 'string' || + slot.length === 0 || + new TextEncoder().encode(slot).byteLength > 256 || + hasAsciiControl(slot) || + /[\uD800-\uDFFF]/u.test(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/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index d0ba05ab8..ab469f8fd 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -8,163 +8,12 @@ import { getAllUnits, firstSize } from './registry'; import { createAdIframe, findSlot, buildCreativeDocument, sanitizeCreativeHtml } from './render'; import { isEffectivelyVisible, recordRender, stampCreativeTrace } from './trace'; -const REQUEST_ADS_DEFAULT_TIMEOUT_MS = 10_000; -const REQUEST_ADS_MAX_SLOTS = 256; -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; - for (const key of Object.getOwnPropertyNames(value)) { - 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 || - Object.getOwnPropertyNames(value).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' - ? (Reflect.apply(abortSignalAbortedGetter, signal, []) as boolean) - : undefined; - } catch { - return undefined; - } -} - -/** 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 || - !Object.keys(options).every((key) => key === 'slots' || key === 'timeoutMs' || key === 'signal') - ) { - 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 (const slot of rawSlots) { - if ( - typeof slot !== 'string' || - slot.length === 0 || - new TextEncoder().encode(slot).byteLength > 256 || - /[\p{Cc}]/u.test(slot) || - /[\uD800-\uDFFF]/u.test(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 }); -} +export { + RequestAdsInputError, + type RequestAdsInputErrorCode, + type ValidatedRequestAdsOptions, + validateRequestAdsOptions, +} from './contracts/request_ads'; export type RequestAdsCallback = () => void; export interface LegacyRequestAdsOptions { diff --git a/crates/trusted-server-js/lib/src/kernel/fallback.ts b/crates/trusted-server-js/lib/src/kernel/fallback.ts index 7276ba00d..00ecf0206 100644 --- a/crates/trusted-server-js/lib/src/kernel/fallback.ts +++ b/crates/trusted-server-js/lib/src/kernel/fallback.ts @@ -1,12 +1,12 @@ import { parseCacheFetchPolicyV1 } from '../core/config'; import { parseBrowserAuctionProjectionV1 } from '../core/contracts/auction_projection'; +import { validateRequestAdsOptions } from '../core/contracts/request_ads'; import { log } from '../core/log'; import { prepareProgrammaticAdUnits } from '../core/registry'; -import { validateRequestAdsOptions } from '../core/request'; import type { BootManifestV1 } from '../core/types'; export { AdUnitRegistrationError, type AdUnitRegistrationErrorCode } from '../core/registry'; -export { RequestAdsInputError, type RequestAdsInputErrorCode } from '../core/request'; +export { RequestAdsInputError, type RequestAdsInputErrorCode } from '../core/contracts/request_ads'; import type { BootFailureReason } from './integration_registry'; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 1939f954e..97bfcc5c5 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -820,4 +820,152 @@ describe('browser composition', () => { expect(latePreparation).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }); + + it('exercises transactional addAdUnits and invocation-time requestAds snapshots through the test kernel', async () => { + const target = {}; + 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); + return { + ok: true, + json: async () => ({ + id: `auction-${requestBodies.length}`, + cur: 'USD', + seatbid: [], + ext: { + trusted_server: { + slot_results: { + version: 1, + auctionId: `auction-${requestBodies.length}`, + results: slots.map((slot) => ({ slot, outcome: 'no_bid' })), + }, + }, + }, + }), + }; + }); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId: 'a'.repeat(64), + manifest: { + version: 1, + releaseId: 'a'.repeat(64), + integrations: [{ id: 'context_test', required: true }], + }, + knownIntegrationIds: Object.freeze(['context_test']), + boot: { + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'server-slot', outcome: 'no_bid' }], + }, + 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() }, + } + ); + + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration({ + id: 'context_test', + release: 'a'.repeat(64), + prepare: () => ({ activate: vi.fn() }), + }) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + 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']); + 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']); + await expect(api.requestAds({ slots: ['unknown', 'programmatic-slot'] })).resolves.toEqual({ + slots: [ + { slot: 'unknown', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, + { slot: 'programmatic-slot', path: 'primary', outcome: 'no_bid' }, + ], + }); + 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); + + composition.runtime.dispose(); + }); }); 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 26db64b26..48625dcf0 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -86,6 +86,13 @@ describe('requestAds input contract', () => { timeoutMs: 100, }); }); + + it('uses the registered-slot ASCII-control grammar instead of rejecting other Unicode controls', () => { + expect(validateRequestAdsOptions({ slots: ['slot\u0085id'] })).toMatchObject({ + slots: ['slot\u0085id'], + }); + expectInputError(() => validateRequestAdsOptions({ slots: ['slot\u007fid'] }), 'invalid_slots'); + }); }); describe('request.requestAds', () => { From e5a134101fe8fe6ae35f9764b18381c47aa9bdf9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:59:20 -0700 Subject: [PATCH 066/194] Publish the hard-cutover TSJS types --- .../lib/src/core/global.d.ts | 6 +- .../trusted-server-js/lib/src/core/index.ts | 12 +- .../trusted-server-js/lib/src/core/trace.ts | 6 +- .../trusted-server-js/lib/src/core/types.ts | 103 +++++++++++++++++- crates/trusted-server-js/lib/src/index.ts | 27 ++++- .../lib/src/integrations/aps/render.ts | 4 +- .../lib/src/integrations/gpt/index.ts | 24 ++-- .../src/integrations/gpt_diagnostics/index.ts | 4 +- .../lib/src/integrations/testlight/index.ts | 10 +- .../lib/src/shared/globals.ts | 4 +- .../lib/test/core/index.test.ts | 18 +-- .../lib/test/core/public_types.test.ts | 43 ++++++++ .../lib/test/core/trace.test.ts | 16 +-- .../lib/test/integrations/gpt/ad_init.test.ts | 8 +- .../integrations/gpt/gpt_bootstrap.test.ts | 6 +- .../lib/test/integrations/gpt/index.test.ts | 6 +- .../gpt/schedule_initial_ad_init.test.ts | 4 +- .../test/integrations/gpt/spa_hook.test.ts | 4 +- .../gpt_diagnostics/index.test.ts | 4 +- .../test/integrations/prebid/index.test.ts | 8 +- 20 files changed, 241 insertions(+), 76 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/core/public_types.test.ts 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 9b21ab312..21fbb29da 100644 --- a/crates/trusted-server-js/lib/src/core/global.d.ts +++ b/crates/trusted-server-js/lib/src/core/global.d.ts @@ -1,10 +1,10 @@ -import type { TsjsApi } from './types'; +import type { LegacyTsjsApi } from './types'; declare global { interface Window { /** Publisher-owned object identity is retained through dormant Task 8 bootstrap tests. */ - tsjs?: TsjsApi; - pbjs?: TsjsApi; + tsjs?: LegacyTsjsApi; + pbjs?: LegacyTsjsApi; } } diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index 2806354b3..807292008 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -4,11 +4,11 @@ export type { GptDiagnosticsApi, GptDiagnosticsExportV1, GptDiagnosticsRequestCycle, - TsjsApi, + LegacyTsjsApi, } from './types'; // Erased coordinated-cutover types only. Production ownership remains below until Task 19. export type { Runtime, RuntimeOptions, RuntimeState } from '../kernel/runtime'; -import type { TsjsApi } from './types'; +import type { LegacyTsjsApi } from './types'; import { addAdUnits } from './registry'; import { renderAdUnit, renderAllAdUnits } from './render'; import { log } from './log'; @@ -18,16 +18,16 @@ import { installQueue } from './queue'; const VERSION = '0.1.0'; -const w: Window & { tsjs?: TsjsApi } = +const w: Window & { tsjs?: LegacyTsjsApi } = ((globalThis as unknown as { window?: Window }).window as Window & { - tsjs?: TsjsApi; - }) || ({} as Window & { tsjs?: TsjsApi }); + tsjs?: LegacyTsjsApi; + }) || ({} as Window & { tsjs?: LegacyTsjsApi }); // Collect existing tsjs queued fns before we overwrite const pending: Array<() => void> = Array.isArray(w.tsjs?.que) ? [...w.tsjs.que] : []; // Create API and attach methods -const api: TsjsApi = (w.tsjs ??= {} as TsjsApi); +const api: LegacyTsjsApi = (w.tsjs ??= {} as LegacyTsjsApi); api.version = VERSION; api.addAdUnits = addAdUnits; api.renderAdUnit = renderAdUnit; diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 2acc50900..67c9d07d6 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -8,7 +8,7 @@ // 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'; +import type { LegacyTsjsApi, RenderRecord } from './types'; /** CustomEvent fired on window after each render-trace record is written. */ export const RENDER_EVENT_NAME = 'tsjs:adRendered'; @@ -48,7 +48,7 @@ let fallbackRenderSeq = 0; */ function nextRenderSeq(): number { try { - const ts = (window.tsjs ??= {} as TsjsApi); + const ts = (window.tsjs ??= {} as LegacyTsjsApi); const next = Math.max(ts.renderSeq ?? 0, fallbackRenderSeq) + 1; ts.renderSeq = next; fallbackRenderSeq = next; @@ -445,7 +445,7 @@ export function renderTracePanel(): void { 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 ts = (window.tsjs ??= {} as LegacyTsjsApi); const renders = (ts.renders ??= {}); const prev = renders[record.slotId]; if (prev) full.count = prev.count + 1; diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 9335dda00..7f8d5c798 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -414,7 +414,108 @@ export interface RequestAdsResult { readonly slots: readonly RequestAdsSlotResult[]; } -export interface TsjsApi { +export type TsjsLogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'; + +export interface TsjsLog { + setLevel(level: TsjsLogLevel): void; + getLevel(): TsjsLogLevel; + error(...values: readonly unknown[]): void; + warn(...values: readonly unknown[]): void; + info(...values: readonly unknown[]): void; + debug(...values: readonly unknown[]): void; +} + +export interface TsjsCommandQueue { + readonly length: 0; + push(callback: unknown): 0; +} + +export interface CreativeBootV1 { + readonly version: 1; + readonly enabled: boolean; + readonly clickGuard: boolean; + readonly renderGuard: boolean; +} + +export interface DiagnosticsBootV1 { + readonly version: 1; + readonly renderTraceOverlay: boolean; + readonly gpt: Readonly<{ readonly active: boolean }>; +} + +export 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; + +/** Pre-cutover bundle implementation shape. Deleted with the unreachable legacy core. */ +export interface LegacyTsjsApi { version: string; que: Array<() => void>; addAdUnits(units: AdUnit | AdUnit[]): void; 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/render.ts b/crates/trusted-server-js/lib/src/integrations/aps/render.ts index 3421ab579..ecf3566af 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -1,5 +1,5 @@ import { log } from '../../core/log'; -import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types'; +import type { ApsPrebidRendererEntry, ApsRendererV1, LegacyTsjsApi } from '../../core/types'; import { validateApsRenderer } from '../../core/contracts/aps_renderer'; import type { MessagingAdapter, MessagingChannel } from '../../adapters/messaging'; import type { @@ -164,7 +164,7 @@ export function registerApsPrebidRenderer( 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 tsjs = (window.tsjs ??= {} as LegacyTsjsApi); const registry = (tsjs.apsPrebidRenderers ??= Object.create(null) as Record< string, ApsPrebidRendererEntry diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 22e28d5e8..3848087ff 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -6,7 +6,7 @@ import type { AuctionBidData, BrowserAuctionBidV1, GptSlotHandoff, - TsjsApi, + LegacyTsjsApi, } from '../../core/types'; import { APS_UNIVERSAL_CREATIVE_RENDERER, @@ -78,7 +78,7 @@ export function prepareTrustedServerGptTargetingV1( return Object.freeze(targeting); } -function bumpRenderGeneration(ts: TsjsApi): number { +function bumpRenderGeneration(ts: LegacyTsjsApi): number { const next = (ts.renderGeneration ?? 0) + 1; ts.renderGeneration = next; return next; @@ -572,7 +572,7 @@ function queueWinBillingBeacon(url: string): boolean { * `installTsAdInit` runs, so the detector is still queued ahead of the * publisher's GPT setup. */ -function syncInitialLoadDisabled(gpt: Partial, ts: TsjsApi): boolean { +function syncInitialLoadDisabled(gpt: Partial, ts: LegacyTsjsApi): boolean { if (typeof gpt.getConfig !== 'function') return false; const config = gpt.getConfig('disableInitialLoad'); @@ -582,7 +582,7 @@ function syncInitialLoadDisabled(gpt: Partial, ts: TsjsApi): boolean return true; } -function installInitialLoadDetector(ts: TsjsApi): void { +function installInitialLoadDetector(ts: LegacyTsjsApi): void { const win = window as GptWindow; const cmd = win.googletag?.cmd; if (!cmd) return; @@ -635,7 +635,7 @@ function findGptSlotByElementId( return pubads.getSlots?.().find((slot) => slot.getSlotElementId() === elementId); } -function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | undefined { +function handoffForSlot(ts: LegacyTsjsApi, slot: GoogleTagSlot): GptSlotHandoff | undefined { return ts.gptSlotHandoffs?.[slot.getSlotElementId()]; } @@ -658,7 +658,7 @@ function handoffFormatsMatch(handoff: GptSlotHandoff, formats: Array, @@ -680,11 +680,11 @@ function matchingHandoff( return matching.length === 1 ? matching[0] : undefined; } -function registerHandoffAlias(ts: TsjsApi, elementId: string, handoff: GptSlotHandoff): void { +function registerHandoffAlias(ts: LegacyTsjsApi, elementId: string, handoff: GptSlotHandoff): void { (ts.gptSlotHandoffs ??= {})[elementId] = handoff; } -function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { +function withGptSlotHandoffInternal(ts: LegacyTsjsApi, callback: () => T): T { const wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; try { @@ -704,7 +704,7 @@ function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { * the original div is gone. The first duplicate publisher request is suppressed * because TS has already issued the initial request with TS targeting. */ -function installLatePublisherSlotHandoff(ts: TsjsApi): void { +function installLatePublisherSlotHandoff(ts: LegacyTsjsApi): void { const win = window as GptWindow; const cmd = win.googletag?.cmd; if (!cmd) return; @@ -859,7 +859,7 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { * riding rAF keeps a single code path whose post-hydration-commit guarantee * holds whenever the request is actually issued. */ -function installScheduleInitialAdInit(ts: TsjsApi): void { +function installScheduleInitialAdInit(ts: LegacyTsjsApi): void { ts.scheduleInitialAdInit = function (initialBids?: Record) { if ((ts.navGeneration ?? 0) !== 0) return; if (initialBids) ts.bids = initialBids; @@ -881,7 +881,7 @@ function installScheduleInitialAdInit(ts: TsjsApi): void { } export function installTsAdInit(): void { - const ts = (window.tsjs ??= {} as TsjsApi); + const ts = (window.tsjs ??= {} as LegacyTsjsApi); installInitialLoadDetector(ts); installScheduleInitialAdInit(ts); installLatePublisherSlotHandoff(ts); @@ -1289,7 +1289,7 @@ function waitForSlotElements(slots: AuctionSlot[], signal: AbortSignal): Promise */ export function installSpaAuctionHook(): void { if (typeof window === 'undefined') return; - const ts = (window.tsjs ??= {} as TsjsApi); + const ts = (window.tsjs ??= {} as LegacyTsjsApi); if (ts.spaHookInstalled) return; ts.spaHookInstalled = true; // Navigation identity for the deferred initial-adInit bootstrap (see diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts index 5cb5c9daf..1265585b0 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts @@ -1,5 +1,5 @@ import { log } from '../../core/log'; -import type { GptDiagnosticsApi, TsjsApi } from '../../core/types'; +import type { GptDiagnosticsApi, LegacyTsjsApi } from '../../core/types'; import { GptDiagnosticsApiController } from './api'; import { GptDiagnosticsBadgeManager } from './badges'; @@ -19,7 +19,7 @@ type GptDiagnosticsWindow = Window & GptObserverWindow & { __tsjs_gpt_diagnostics_active?: boolean; __tsjs_gpt_diagnostics_runtime?: GptDiagnosticsRuntime; - tsjs?: TsjsApi; + tsjs?: LegacyTsjsApi; }; /** Whether the early bootstrap activated diagnostics for this document. */ diff --git a/crates/trusted-server-js/lib/src/integrations/testlight/index.ts b/crates/trusted-server-js/lib/src/integrations/testlight/index.ts index 7f2598b17..8424d20af 100644 --- a/crates/trusted-server-js/lib/src/integrations/testlight/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/testlight/index.ts @@ -1,4 +1,4 @@ -import type { TsjsApi } from '../../core/types'; +import type { LegacyTsjsApi } from '../../core/types'; import { installQueue } from '../../core/queue'; import { log } from '../../core/log'; import { resolvePrebidWindow } from '../../shared/globals'; @@ -14,9 +14,9 @@ type TestlightWindow = PrebidWindow & { testlight?: TestlightGlobal; }; -function ensureTsjsApi(win: TestlightWindow): TsjsApi { +function ensureTsjsApi(win: TestlightWindow): LegacyTsjsApi { if (win.tsjs) return win.tsjs; - const stub: TsjsApi = { + const stub: LegacyTsjsApi = { version: '0.0.0', que: [], addAdUnits: () => undefined, @@ -27,13 +27,13 @@ function ensureTsjsApi(win: TestlightWindow): TsjsApi { return stub; } -function installTestlightQueue(api: TsjsApi, win: TestlightWindow): void { +function installTestlightQueue(api: LegacyTsjsApi, win: TestlightWindow): void { if (!Array.isArray(api.que)) { installQueue(api, win); } } -function flushCallbacks(queue: TestlightCallback[], api: TsjsApi): void { +function flushCallbacks(queue: TestlightCallback[], api: LegacyTsjsApi): void { while (queue.length > 0) { const fn = queue.shift(); if (typeof fn !== 'function') { diff --git a/crates/trusted-server-js/lib/src/shared/globals.ts b/crates/trusted-server-js/lib/src/shared/globals.ts index cbacb590f..7bb838d82 100644 --- a/crates/trusted-server-js/lib/src/shared/globals.ts +++ b/crates/trusted-server-js/lib/src/shared/globals.ts @@ -1,5 +1,5 @@ // Cross-runtime helpers for resolving windows/globals in creatives and pbjs shims. -import type { TsjsApi } from '../core/types'; +import type { LegacyTsjsApi } from '../core/types'; export interface TsCreativeApi { installGuards(): void; @@ -34,7 +34,7 @@ export function resolveWindow(): Window | undefined { return maybeWindow; } -export type PrebidWindow = Window & { tsjs?: TsjsApi; pbjs?: TsjsApi }; +export type PrebidWindow = Window & { tsjs?: LegacyTsjsApi; pbjs?: LegacyTsjsApi }; // Always hand back an object so shims can safely assign tsjs/pbjs globals. export function resolvePrebidWindow(): PrebidWindow { 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..a46efa57c 100644 --- a/crates/trusted-server-js/lib/test/core/index.test.ts +++ b/crates/trusted-server-js/lib/test/core/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import type { AuctionBidData, AuctionSlot, TsjsApi } from '../../src/core/types'; +import type { AuctionBidData, AuctionSlot, LegacyTsjsApi } from '../../src/core/types'; const ORIGINAL_FETCH = global.fetch; @@ -17,7 +17,7 @@ describe('core/index', () => { it('initializes tsjs API with expected surface', async () => { await import('../../src/core/index'); - const api = window.tsjs as TsjsApi; + const api = window.tsjs as LegacyTsjsApi; expect(api).toBeDefined(); expect(typeof api.version).toBe('string'); expect(Array.isArray(api.que)).toBe(true); @@ -31,7 +31,7 @@ describe('core/index', () => { it('defaults adSlots and bids so gated-off pages never see undefined', async () => { await import('../../src/core/index'); - const api = window.tsjs as TsjsApi; + const api = window.tsjs as LegacyTsjsApi; expect(api.adSlots).toEqual([]); expect(api.bids).toEqual({}); }); @@ -40,7 +40,7 @@ describe('core/index', () => { window.tsjs = { adSlots: [{ id: 'pre-injected' } as AuctionSlot], bids: { 'pre-injected': { hb_pb: '1.00' } } as Record, - } as TsjsApi; + } as LegacyTsjsApi; await import('../../src/core/index'); @@ -49,10 +49,10 @@ describe('core/index', () => { }); it('flushes queued callbacks that existed before initialization', async () => { - const callback = vi.fn(function (this: TsjsApi) { + const callback = vi.fn(function (this: LegacyTsjsApi) { expect(this).toBe(window.tsjs); }); - window.tsjs = { que: [callback] as Array<() => void> } as TsjsApi; + window.tsjs = { que: [callback] as Array<() => void> } as LegacyTsjsApi; await import('../../src/core/index'); @@ -61,7 +61,7 @@ describe('core/index', () => { it('installs queue that executes callbacks immediately with api context', async () => { await import('../../src/core/index'); - const api = window.tsjs as TsjsApi; + const api = window.tsjs as LegacyTsjsApi; const fn = vi.fn(); api.que.push(fn); @@ -72,7 +72,7 @@ describe('core/index', () => { it('renders registered ad units using core rendering helpers', async () => { await import('../../src/core/index'); - const api = window.tsjs as TsjsApi; + const api = window.tsjs as LegacyTsjsApi; api.addAdUnits([ { code: 'slot-1', mediaTypes: { banner: { sizes: [[300, 250]] } } }, @@ -88,7 +88,7 @@ describe('core/index', () => { 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; + const api = window.tsjs as LegacyTsjsApi; expect(api.requestAds).toBe(requestAds); }); 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/trace.test.ts b/crates/trusted-server-js/lib/test/core/trace.test.ts index 3317f67e1..6c80f2e6f 100644 --- a/crates/trusted-server-js/lib/test/core/trace.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace.test.ts @@ -12,7 +12,7 @@ import { TRACE_PANEL_ID, TRACE_BADGE_CLASS, } from '../../src/core/trace'; -import type { RenderRecord, TsjsApi } from '../../src/core/types'; +import type { LegacyTsjsApi, RenderRecord } from '../../src/core/types'; function clearTraceCookie(): void { document.cookie = 'ts-trace=; Max-Age=0; Path=/'; @@ -24,7 +24,7 @@ function removePanel(): void { describe('trace/recordRender', () => { beforeEach(() => { - delete (window as { tsjs?: TsjsApi }).tsjs; + delete (window as { tsjs?: LegacyTsjsApi }).tsjs; clearTraceCookie(); removePanel(); }); @@ -215,7 +215,7 @@ describe('trace/floating panel', () => { }; beforeEach(() => { - delete (window as { tsjs?: TsjsApi }).tsjs; + delete (window as { tsjs?: LegacyTsjsApi }).tsjs; clearTraceCookie(); removePanel(); }); @@ -377,10 +377,10 @@ describe('trace/floating panel', () => { 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 = { + (window as { tsjs?: LegacyTsjsApi }).tsjs = { renders: { 'slot-1': liveRecord }, renderLog: [oldRecord, liveRecord], - } as unknown as TsjsApi; + } as unknown as LegacyTsjsApi; renderTracePanel(); @@ -403,9 +403,9 @@ describe('trace/floating panel', () => { }); it('renderTracePanel is a no-op while disarmed even if renders exist', () => { - (window as { tsjs?: TsjsApi }).tsjs = { + (window as { tsjs?: LegacyTsjsApi }).tsjs = { renders: { 'slot-1': { ...record, count: 1, seq: 1, at: 1 } }, - } as unknown as TsjsApi; + } as unknown as LegacyTsjsApi; renderTracePanel(); expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); }); @@ -429,7 +429,7 @@ describe('trace/floating panel', () => { describe('trace/confirmation badge', () => { beforeEach(() => { - delete (window as { tsjs?: TsjsApi }).tsjs; + delete (window as { tsjs?: LegacyTsjsApi }).tsjs; clearTraceCookie(); document.body.innerHTML = ''; }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 71ee3bc5f..7bd23fdfd 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -8,7 +8,7 @@ import type { BidRenderSourceV1, BrowserAuctionBidV1, GptSlotHandoff, - TsjsApi, + LegacyTsjsApi, } from '../../../src/core/types'; function apsRenderer() { @@ -143,11 +143,11 @@ interface PrebidResponseMessage { height?: number; } -// `tsjs` is declared globally as the full `TsjsApi` (core/types.ts). Omitting +// `tsjs` is declared globally as the full legacy API (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. +// that would force every fixture below to satisfy the whole legacy API shape. type TestGptSlotHandoff = Omit & { formats: number[][] }; -type TestTsjsApi = Omit, 'gptSlotHandoffs'> & { +type TestTsjsApi = Omit, 'gptSlotHandoffs'> & { gptSlotHandoffs?: Record | undefined; }; type TestWindow = Omit & { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index 587cf59cf..79c86fef0 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { TsjsApi } from '../../../src/core/types'; +import type { LegacyTsjsApi } from '../../../src/core/types'; /** * Executable coverage for the edge-injected `gpt_bootstrap.js` — the @@ -46,11 +46,11 @@ interface MockGoogleTag { display: (divId: string) => void; } -// `tsjs` is declared globally as the full `TsjsApi`; `Omit` drops it from +// `tsjs` is declared globally as the full legacy API; `Omit` drops it from // `Window` so the fixtures below only have to satisfy the fields they set. type TestWindow = Omit & { googletag?: MockGoogleTag; - tsjs?: Partial; + tsjs?: Partial; }; function runBootstrap(): void { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts index 82ddc5b88..d50b697f4 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Mock } from 'vitest'; -import type { TsjsApi } from '../../../src/core/types'; +import type { LegacyTsjsApi } from '../../../src/core/types'; // We import installGptShim dynamically so each test can control whether the // GPT enable flag is present before module evaluation. @@ -244,10 +244,10 @@ describe('GPT – installTsAdInit', () => { enableServices: Mock; } - // `tsjs` is declared globally as the full `TsjsApi`; `Omit` drops it from + // `tsjs` is declared globally as the full legacy API; `Omit` drops it from // `Window` so the fixture below only has to satisfy the fields it sets. type AdInitWindow = Omit & { - tsjs?: Partial; + tsjs?: Partial; googletag?: MockGoogleTag; }; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts index 889c189ea..07bfde6f5 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { TsjsApi } from '../../../src/core/types'; +import type { LegacyTsjsApi } from '../../../src/core/types'; type TestWindow = Window & { googletag?: unknown; - tsjs?: TsjsApi; + tsjs?: LegacyTsjsApi; }; const originalPushState = history.pushState.bind(history); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index f1cb4d84f..139edcd34 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { TsjsApi } from '../../../src/core/types'; +import type { LegacyTsjsApi } from '../../../src/core/types'; type TestWindow = Window & { googletag?: unknown; - tsjs?: TsjsApi; + tsjs?: LegacyTsjsApi; }; const originalPushState = history.pushState.bind(history); 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 69a4508f3..5badf7638 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { TsjsApi } from '../../../src/core/types'; +import type { LegacyTsjsApi } from '../../../src/core/types'; import { installGptDiagnosticsRuntime, isGptDiagnosticsActive, @@ -18,7 +18,7 @@ type DiagnosticsTestWindow = NonNullable | undefined; + tsjs?: Partial | undefined; googletag?: unknown; __tsjs_prebid?: Record | undefined; __tsjsPrebidShimInstalled?: boolean | undefined; @@ -197,7 +197,11 @@ import { prepareTrustedServerPrebidBidV1, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; -import type { BidRenderSourceV1, BrowserAuctionBidV1, TsjsApi } from '../../../src/core/types'; +import type { + BidRenderSourceV1, + BrowserAuctionBidV1, + LegacyTsjsApi, +} from '../../../src/core/types'; import { log } from '../../../src/core/log'; import envelope from '../../fixtures/aps-renderer-v1.json'; From 55a29318c5a9c61520d355e5289adf5f03acc281 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:03:38 -0700 Subject: [PATCH 067/194] Harden direct auction identity boundaries --- .../lib/src/composition/browser.ts | 77 ++++++++---- .../lib/src/services/auction_batch.ts | 65 ++++++++-- .../lib/src/services/slots.ts | 18 ++- .../lib/test/composition/browser.test.ts | 111 +++++++++++++++++- .../lib/test/core/registry.test.ts | 29 +++++ .../lib/test/services/auction_batch.test.ts | 62 ++++++++++ .../lib/test/services/slots.test.ts | 8 +- 7 files changed, 321 insertions(+), 49 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 89fe7ac98..a99a80e22 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -24,12 +24,12 @@ import { parseBrowserAuctionProjectionV1, } from '../core/contracts/auction_projection'; import { validateApsRenderer } from '../core/contracts/aps_renderer'; +import { validateRequestAdsOptions } from '../core/contracts/request_ads'; import { AdUnitRegistrationError, addAdUnitsResult, prepareProgrammaticAdUnits, } from '../core/registry'; -import { validateRequestAdsOptions } from '../core/request'; import { prepareAdmIframe } from '../core/render'; import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; @@ -61,7 +61,12 @@ import { type RendererNonceRegistry, } from '../services/render'; import { createPucBridge, type PucBridge, type PucBridgeOptions } from '../services/puc_bridge'; -import { createSlotService, type SlotRecord, type SlotService } from '../services/slots'; +import { + createSlotService, + type SlotRecord, + type SlotRegistrationFailure, + type SlotService, +} from '../services/slots'; import { createTargetingService, type TargetingService } from '../services/targeting'; export interface BrowserAdapters { @@ -262,30 +267,45 @@ export function createTestBrowserRuntimeComposition( ), }); }; + 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; - const snapshot = navigation && slots?.snapshotRegisteredSlots(navigation); - if (!navigation || !slots || !snapshot) throw new Error('TSJS navigation is unavailable'); + 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); - const registered = slots.register( - navigation, - prepared.map((unit) => ({ - directAuctionUnit: unit, - registeredSlotId: unit.code, - source: 'programmatic' as const, - })) - ); - if (!registered.ok) { - if (registered.reason === 'registry_capacity') { - throw new AdUnitRegistrationError('registry_capacity'); - } - if (registered.reason === 'duplicate_slot') { - throw new AdUnitRegistrationError('slot_collision'); - } - throw new Error('TSJS navigation changed during registration'); + 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 => { @@ -507,13 +527,19 @@ export function createTestBrowserRuntimeComposition( }; const resolveDirectContainer = (record: SlotRecord): HTMLElement | undefined => { try { - if (typeof document === 'undefined' || record.domAliases.length === 0) return undefined; - const aliases = new Set(record.domAliases); + 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 && aliases.has(element.id)) matches.add(element); + if (element instanceof HTMLElement && identifiers.has(element.id)) { + matches.add(element); + } } return matches.size === 1 ? Array.from(matches)[0] : undefined; } catch { @@ -527,7 +553,10 @@ export function createTestBrowserRuntimeComposition( createRenderAttempt({ artifacts, owner, - prepareRenderSource: (candidate) => parseBidRenderSourceV1(candidate, cachePolicy), + prepareRenderSource: (candidate) => { + const source = parseBidRenderSourceV1(candidate, cachePolicy); + return source ? Object.freeze(source) : undefined; + }, reservations: reservationService, }), fetcher: (input, init) => { diff --git a/crates/trusted-server-js/lib/src/services/auction_batch.ts b/crates/trusted-server-js/lib/src/services/auction_batch.ts index 24dee8c92..eb7048e07 100644 --- a/crates/trusted-server-js/lib/src/services/auction_batch.ts +++ b/crates/trusted-server-js/lib/src/services/auction_batch.ts @@ -9,6 +9,23 @@ import type { } from './render'; const DEFAULT_AUCTION_ENDPOINT = '/auction'; +const reflectApplyIntrinsic = Reflect.apply; +function captureAbortSignalMethod(name: 'addEventListener' | 'removeEventListener'): unknown { + if (typeof AbortSignal === 'undefined') return undefined; + let prototype: object | null = AbortSignal.prototype; + while (prototype) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, name); + if (descriptor && 'value' in descriptor) return descriptor.value; + prototype = Object.getPrototypeOf(prototype) as object | null; + } + return undefined; +} +const abortSignalAbortedGetter = + typeof AbortSignal === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; +const abortSignalAddEventListener = captureAbortSignalMethod('addEventListener'); +const abortSignalRemoveEventListener = captureAbortSignalMethod('removeEventListener'); export type AuctionBatchFetcher = (input: string, init: RequestInit) => Promise; @@ -114,6 +131,34 @@ function cancelledResult(slot: string, reason: RenderCancellationReason): Auctio return terminalResult(slot, frozen({ outcome: 'cancelled' as const, reason })); } +function signalAborted(signal: AbortSignal): boolean | undefined { + if (typeof abortSignalAbortedGetter !== 'function') return undefined; + try { + return reflectApplyIntrinsic(abortSignalAbortedGetter, signal, []) as boolean; + } catch { + return undefined; + } +} + +function addAbortListener(signal: AbortSignal, listener: () => void): boolean { + if (typeof abortSignalAddEventListener !== 'function') return false; + try { + reflectApplyIntrinsic(abortSignalAddEventListener, signal, ['abort', listener, { once: true }]); + return true; + } catch { + return false; + } +} + +function removeAbortListener(signal: AbortSignal, listener: () => void): void { + if (typeof abortSignalRemoveEventListener !== 'function') return; + try { + reflectApplyIntrinsic(abortSignalRemoveEventListener, signal, ['abort', listener]); + } catch { + // Logical cancellation authority is already detached. + } +} + function responseMembershipIsExact( parsed: ParsedAuctionBatchResponse, slots: readonly string[] @@ -207,11 +252,7 @@ export function createAuctionBatchService( const cleanupSignal = (): void => { if (!callerListener || !input.signal) return; - try { - input.signal.removeEventListener('abort', callerListener); - } catch { - // A hostile signal cannot retain batch authority. - } + removeAbortListener(input.signal, callerListener); callerListener = undefined; }; @@ -382,19 +423,17 @@ export function createAuctionBatchService( finishIfComplete(); return publicBatch; } - if (input.signal?.aborted === true) { - cancelLive('caller_aborted'); - return publicBatch; - } if (input.signal) { + if (signalAborted(input.signal) !== false) { + cancelLive('caller_aborted'); + return publicBatch; + } callerListener = (): void => cancelLive('caller_aborted'); - try { - input.signal.addEventListener('abort', callerListener, { once: true }); - } catch { + if (!addAbortListener(input.signal, callerListener)) { cancelLive('caller_aborted'); return publicBatch; } - if (Reflect.get(input.signal, 'aborted') === true) { + if (signalAborted(input.signal) !== false) { cancelLive('caller_aborted'); return publicBatch; } diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 53fcfde7c..d0fd7f3a3 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -371,12 +371,18 @@ function resolveUnique( } function validSlotIdentity(value: string): boolean { - return ( - value.length > 0 && - new TextEncoder().encode(value).length <= 256 && - !/[\p{Cc}]/u.test(value) && - !/[\uD800-\uDFFF]/u.test(value) - ); + if ( + value.length === 0 || + new TextEncoder().encode(value).length > 256 || + /[\uD800-\uDFFF]/u.test(value) + ) { + return false; + } + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return false; + } + return true; } function frozenAliases(aliases: readonly string[] | undefined): readonly string[] | undefined { diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 97bfcc5c5..3ea863325 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -834,18 +834,57 @@ describe('browser composition', () => { }; 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: [], + 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, outcome: 'no_bid' })), + results: slots.map((slot) => + slot === winnerSlot + ? { slot, outcome: 'winner', candidateId } + : { slot, outcome: 'no_bid' } + ), }, }, }, @@ -917,6 +956,13 @@ describe('browser composition', () => { 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([ { @@ -930,10 +976,18 @@ describe('browser composition', () => { ]) ).toThrowError(expect.objectContaining({ code: 'slot_collision', unitIndex: 1 })); expect(composition.projectionSlotsForTest()).toEqual(['server-slot', 'programmatic-slot']); - await expect(api.requestAds({ slots: ['unknown', 'programmatic-slot'] })).resolves.toEqual({ + document.body.innerHTML = '
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: 'no_bid' }, + { slot: 'programmatic-slot', path: 'primary', outcome: 'accepted' }, ], }); expect(requestBodies[0]).toEqual({ @@ -966,6 +1020,55 @@ describe('browser composition', () => { 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); + composition.runtime.dispose(); + expect(() => api.addAdUnits(programmatic)).toThrowError( + expect.objectContaining({ name: 'AdUnitRegistrationError', code: 'slot_collision' }) + ); + document.body.innerHTML = ''; }); }); 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 cc0fe6283..d94e463a0 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -157,10 +157,39 @@ describe('registry', () => { ) ).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' + ); + }); }); 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 index 617a10e2d..48d935289 100644 --- a/crates/trusted-server-js/lib/test/services/auction_batch.test.ts +++ b/crates/trusted-server-js/lib/test/services/auction_batch.test.ts @@ -258,6 +258,35 @@ describe('auction batch service', () => { } }); + 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' }])); @@ -594,4 +623,37 @@ describe('auction batch service', () => { }); 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/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 7c507c50b..5c859c2f6 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -211,7 +211,7 @@ function bindTrustedSlot(service: SlotService, navigation: NavigationSession, id describe('slot registry', () => { afterEach(() => vi.useRealTimers()); - it('accepts exact nonempty 256-byte ids and rejects empty, 257-byte, NUL, and controls', () => { + 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)}é`; @@ -224,13 +224,17 @@ describe('slot registry', () => { 'a'.repeat(257), 'nul\0id', 'line\nid', - `c1${String.fromCharCode(0x85)}`, + `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', () => { From 03f37250af20d832502738edad91faef789f18c2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:05:17 -0700 Subject: [PATCH 068/194] Account for the direct auction request envelope --- crates/trusted-server-js/lib/src/core/registry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index f2979166b..6e2c8b46e 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -422,8 +422,8 @@ export function prepareProgrammaticAdUnits( const unitsBytes = measureJsonBytes(prepared); if (unitsBytes === undefined) throw new AdUnitRegistrationError('invalid_params'); - // `{"adUnits":` + encoded array + `}`. - if (boundedBytes(12, unitsBytes) > MAX_AUCTION_BODY_BYTES) { + // `{"adUnits":` + encoded array + `,"config":{}}`. + if (boundedBytes(24, unitsBytes) > MAX_AUCTION_BODY_BYTES) { throw new AdUnitRegistrationError('request_body_too_large'); } if (occupied.size + prepared.length > MAX_ACTIVE_SLOT_RECORDS) { From fce5781fce0b88f4452bd110305b0a879faf612e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:06:55 -0700 Subject: [PATCH 069/194] Serialize direct auction bodies without publisher hooks --- .../lib/src/composition/browser.ts | 8 +- .../lib/src/core/registry.ts | 98 +++++++++++++++++++ .../lib/test/core/registry.test.ts | 32 +++++- 3 files changed, 133 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index a99a80e22..f60b9905b 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -29,6 +29,7 @@ import { AdUnitRegistrationError, addAdUnitsResult, prepareProgrammaticAdUnits, + serializeAuctionRequestBody, } from '../core/registry'; import { prepareAdmIframe } from '../core/render'; import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; @@ -362,10 +363,9 @@ export function createTestBrowserRuntimeComposition( ); let requestBody: string; try { - requestBody = JSON.stringify({ adUnits, config: context }); - if (new TextEncoder().encode(requestBody).byteLength > 256 * 1024) { - throw new Error('auction request body exceeds limit'); - } + const serialized = serializeAuctionRequestBody(adUnits, context); + if (!serialized) throw new Error('auction request body exceeds limit'); + requestBody = serialized; } catch { return Promise.resolve( combineRequestResults( diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 6e2c8b46e..0e701b973 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -9,6 +9,11 @@ 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 objectSetPrototypeOfIntrinsic = Object.setPrototypeOf; +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; export type AdUnitRegistrationErrorCode = | 'invalid_units' @@ -201,6 +206,80 @@ function copyJsonRecord(value: unknown): Readonly> | und } } +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([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) { @@ -436,6 +515,25 @@ export function addAdUnitsResult(units: readonly ProgrammaticAdUnit[]): AddAdUni return Object.freeze({ registered: Object.freeze(units.map(({ code }) => code)) }); } +/** Serialize one bounded `/auction` body without consulting inherited `toJSON` hooks. */ +export function serializeAuctionRequestBody( + adUnits: readonly Readonly[], + config: Readonly> +): string | undefined { + try { + const detached = copyJsonForSerialization({ adUnits, config }); + if (!detached) return undefined; + const serialized = reflectApplyIntrinsic(jsonStringifyIntrinsic, JSON, [detached]) as unknown; + if (typeof serialized !== 'string') return undefined; + const bytes = reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [ + serialized, + ]) as Uint8Array; + return bytes.byteLength <= MAX_AUCTION_BODY_BYTES ? serialized : undefined; + } catch { + return undefined; + } +} + // The mutable merge registry remains connected only to the pre-cutover core entry. const legacyRegistry = new Map(); 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 d94e463a0..ff44b062e 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { AdUnit } from '../../src/core/types'; -import { AdUnitRegistrationError, prepareProgrammaticAdUnits } from '../../src/core/registry'; +import { + AdUnitRegistrationError, + prepareProgrammaticAdUnits, + serializeAuctionRequestBody, +} from '../../src/core/registry'; function unit(code = 'programmatic-slot'): Record { return { @@ -192,4 +196,30 @@ describe('registry', () => { 'request_body_too_large' ); }); + + 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'); + } + + expect(publisherHook).not.toHaveBeenCalled(); + expect(body).toBe(JSON.stringify({ adUnits: prepared, config: context })); + }); }); From 691fdd06b2c2ee556200afff743febbfaf9f9fd3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:13:06 -0700 Subject: [PATCH 070/194] Prepare the real TSJS performance marks --- crates/trusted-server-core/src/publisher.rs | 28 +++++- .../lib/src/adapters/googletag.ts | 44 ++++++++- .../lib/test/adapters/googletag.test.ts | 94 +++++++++++++++++++ 3 files changed, 162 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 54dc8bd22..778addf82 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3792,6 +3792,18 @@ else t.bids=b;\ ) } +/// Prospective hard-cutover mark emitted at the bids/projection boundary. +/// +/// Task 19 inserts this already-tested fragment into the production boot path in +/// the same atomic switch that installs the matching first-display mark. +#[allow( + dead_code, + reason = "Task 16 prepares this fragment for the atomic Task 19 production switch" +)] +pub(crate) fn build_bids_script_performance_mark() -> &'static str { + "(function(){try{window.performance.mark(\"tsjs:bids-script\");}catch(_){}})();" +} + /// Build the empty-bids `'); + + 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(); + } + }); +}); From a9718421b4a0bfcf0052c4cebe6d012aa3565011 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:48:37 -0700 Subject: [PATCH 077/194] Wire attributable GPT empty fallback --- .../lib/src/composition/browser.ts | 27 +- .../lib/src/integrations/gpt/module.ts | 136 ++++++++++ .../lib/test/composition/browser.test.ts | 219 +++++++++++++++- .../lib/test/integrations/gpt/module.test.ts | 247 +++++++++++++++++- 4 files changed, 626 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index ee1e33424..705d52282 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -34,6 +34,7 @@ import { } from '../core/registry'; import { prepareAdmIframe } from '../core/render'; import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; +import { startGptSlotOperation, type GptSlotOperationInput } from '../integrations/gpt/module'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; import { createRuntimeSession } from '../kernel/sessions'; @@ -62,9 +63,11 @@ import { 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, @@ -123,6 +126,10 @@ export interface BrowserRuntimeComposition extends BrowserComposition { 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; } export interface BrowserCoreActivations { @@ -450,7 +457,14 @@ export function createTestBrowserRuntimeComposition( parseProjection ); if (!initialProjection) throw new Error('Accepted boot projection is unavailable'); - const slotService = createSlotService({ googletag: composition.adapters.googletag }); + const reconciliation = + typeof document === 'undefined' || typeof MutationObserver === 'undefined' + ? undefined + : createBrowserSlotReconciliationBoundary(document, MutationObserver); + const slotService = createSlotService({ + googletag: composition.adapters.googletag, + ...(reconciliation ? { reconciliation } : {}), + }); const targetingService = createTargetingService(); const reservationService = createReservationService({ prepareRenderSource: (candidate) => parseBidRenderSourceV1(candidate, cachePolicy), @@ -717,5 +731,16 @@ export function createTestBrowserRuntimeComposition( reservationServiceForTest: () => browserServices?.reservations, rendererNonceRegistryForTest: () => browserServices?.rendererNonces, pucBridgeForTest: () => browserServices?.pucBridge, + startGptSlotOperationForTest: ( + input: Omit + ): SlotOperationCreationResult => { + const services = browserServices; + if (!services) return Object.freeze({ ok: false, reason: 'invalid_attempt' }); + return startGptSlotOperation({ + ...input, + pucBridge: services.pucBridge, + slots: services.slots, + }); + }, }); } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts index 7b5392b8e..e709c8003 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -3,6 +3,14 @@ import type { IntegrationPrepareContext, IntegrationRegistration, } from '../../kernel/integration_registry'; +import { + createSlotOperation, + type RenderAttempt, + type SlotOperationCreationResult, + type SlotOperationOptions, +} from '../../services/render'; +import type { PucBridge, PucGamAttemptInput } from '../../services/puc_bridge'; +import type { SlotRequestOutcome, SlotService } from '../../services/slots'; import { installGptGuard, resetGuardState } from './script_guard'; @@ -23,6 +31,134 @@ interface GptIntegrationRuntime { readonly start: (config: unknown) => void; } +export interface GptSlotOperationInput extends Omit { + readonly attempt: RenderAttempt; + readonly createFallback?: SlotOperationOptions['createFallback']; + readonly operation: 'display' | 'refresh'; + readonly pucBridge: Pick; + readonly requestClass: string; + readonly slots: Pick; +} + +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 = 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 validFrozenConfig(candidate: unknown): boolean { const seen = new Set(); let nodes = 0; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 792bbd71b..fd7746ae5 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -26,7 +26,11 @@ import { createGptIntegrationRegistration } from '../../src/integrations/gpt/mod import { isGuardInstalled, resetGuardState } from '../../src/integrations/gpt/script_guard'; import { publicLog } from '../../src/kernel/fallback'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; -import type { RenderAttempt } from '../../src/services/render'; +import { + createRenderAttempt, + type CommittedRenderArtifact, + type RenderAttempt, +} from '../../src/services/render'; function createTarget() { return { @@ -45,6 +49,52 @@ function fakeGoogletagAdapter( return Object.freeze({ ...createNoopGoogletagAdapter(), bindingStatus }); } +function synchronousGptAdapter() { + const listeners = new Map void>>(); + const bindingToken = Object.freeze({}); + const refresh = vi.fn(); + const facade: GoogletagFacade = Object.freeze({ + bindingToken: () => bindingToken, + clearTargeting: vi.fn(), + display: vi.fn(), + getTargeting: vi.fn(() => []), + observeTargeting: () => vi.fn(), + refresh, + serviceState: () => + Object.freeze({ apiReady: true, initialLoadDisabled: false, pubadsReady: true }), + setTargeting: vi.fn(), + slots: () => Object.freeze([]), + 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: () => Object.freeze({ status: 'destroyed' as const }), + }); + const adapter: GoogletagAdapter = Object.freeze({ + bindingStatus: () => 'present', + dispose: vi.fn(), + notifyReady: vi.fn(), + 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() }); + }, + }); + return { + adapter, + emit: (eventType: string, event: unknown): void => { + for (const listener of listeners.get(eventType) ?? []) listener(event); + }, + refresh, + }; +} + function fakePrebidAdapter( bindingStatus: () => PrebidBindingStatus = () => 'pending' ): PrebidAdapter { @@ -119,6 +169,173 @@ describe('browser composition', () => { 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 projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([Object.freeze({ slot: 'slot-one', outcome: 'no_bid' as const })]), + }), + bids: Object.freeze([]), + }); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId: 'a'.repeat(64), + manifest: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, + 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 source = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
fictional fallback
', + width: 300, + height: 250, + }); + 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 reservationId = `r1_${'a'.repeat(22)}`; + const winnerContext = Object.freeze({ selectedCpm: 1 }); + expect( + reservations.registerRender({ + reservationId, + slot: primary.slot, + navigation, + attemptId: primary.id, + renderSource: source, + winnerContext, + }) + ).toMatchObject({ ok: true }); + 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; + let fallback: RenderAttempt | undefined; + const operation = composition.startGptSlotOperationForTest({ + artifact, + attempt: primary, + createFallback: (parentAttemptId) => { + fallback = createAttempt(parentAttemptId); + return Object.freeze({ ok: true as const, value: fallback }); + }, + operation: 'refresh', + owner: ownerResult.value, + requestClass: 'primary', + reservationId, + }); + 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('derives exact APS validation coordinates only for the real browser target', () => { const renderer = { type: 'aps', 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 index 623451d42..234bd74ad 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -1,14 +1,106 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createGptIntegrationRegistration } from '../../../src/integrations/gpt/module'; +import { + createGptIntegrationRegistration, + startGptSlotOperation, + type GptSlotOperationInput, +} from '../../../src/integrations/gpt/module'; 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, + type CommittedRenderArtifact, + type RenderAttempt, +} from '../../../src/services/render'; +import { createReservationService } from '../../../src/services/reservations'; +import type { SlotRequestOutcome } from '../../../src/services/slots'; 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, + primary, + primaryOwner: primaryCreated.owner, + 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 { @@ -206,4 +298,157 @@ describe('transactional GPT integration module', () => { 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 started = startGptSlotOperation({ + artifact: harness.artifact, + attempt: harness.primary, + 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(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, + 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: '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, + 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(); + } + ); }); From 3b5cf8157d53eb4eb38d5e837e94d532328bb523 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:51:19 -0700 Subject: [PATCH 078/194] Measure canonical auction projection bytes --- .../trusted-server-js/lib/src/core/auction.ts | 30 +- .../lib/src/services/slots.ts | 569 +++++++++++++++++- .../lib/test/core/auction.test.ts | 70 ++- .../lib/test/services/slots.test.ts | 380 ++++++++++++ 4 files changed, 1043 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index bfe0ce078..0fac2fd01 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -23,6 +23,7 @@ import type { ApsRendererV1, AuctionDecisionSetV1, BidRenderSourceV1, + BrowserAuctionProjectionV1, SlotAuctionDecisionV1, } from './types'; @@ -210,8 +211,7 @@ export function parseTrustedServerAuctionResponseV1( 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)) || - jsonUtf8ByteLength(value) > MAX_BROWSER_AUCTION_PROJECTION_BYTES + winners.some((winner) => !bids.some((bid) => bid.candidateId === winner.candidateId)) ) { return undefined; } @@ -224,6 +224,32 @@ export function parseTrustedServerAuctionResponseV1( 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, + bids: canonicalBids, + }; + if (jsonUtf8ByteLength(canonicalProjection) > MAX_BROWSER_AUCTION_PROJECTION_BYTES) { + return undefined; + } + return { auction, bids: orderedBids }; } diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index d0fd7f3a3..625c5ec54 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -19,6 +19,9 @@ export const MAX_ACTIVE_SLOT_RECORDS = 256; const GPT_REQUEST_START_TIMEOUT_MS = 3_000; const GPT_COMPLETION_TIMEOUT_MS = 10_000; +const GPT_RECONCILIATION_DEBOUNCE_MS = 250; +const GPT_RECONCILIATION_WINDOW_MS = 5_000; +const MAX_SUCCESSFUL_RECONCILIATIONS = 2; const MAX_PENDING_PUBLISHER_INTENTS = 64; const MAX_SLOT_ALIASES = 256; const MAX_PLACEMENT_QUARANTINE_KEYS = 2_048; @@ -79,6 +82,7 @@ export type SlotRequestFailure = | 'gpt_completion_timeout' | 'gpt_request_failed' | 'gpt_request_timeout' + | 'reconciliation_capacity' | 'slot_quarantined' | 'slot_unresolved'; @@ -151,11 +155,24 @@ export interface SlotService { export interface SlotServiceOptions { readonly googletag: GoogletagAdapter; readonly now?: () => number; + readonly reconciliation?: SlotReconciliationBoundary; +} + +export type SlotReconciliationResolution = + | Readonly<{ status: 'ambiguous' | 'unresolved' }> + | Readonly<{ status: 'unique'; element: object; elementId: string }>; + +/** Narrow DOM ownership boundary used by navigation-scoped slot reconciliation. */ +export interface SlotReconciliationBoundary { + readonly isConnected: (element: object) => boolean; + readonly observe: (callback: () => void) => () => void; + readonly resolve: (elementIds: readonly string[]) => SlotReconciliationResolution; } interface NavigationState { disposed: boolean; nextOrdinal: number; + observerRelease: (() => void) | undefined; readonly owner: NavigationSession; readonly records: Map; } @@ -164,6 +181,8 @@ interface InternalSlotRecord { activeIntent: RequestIntent | undefined; physical: PhysicalSlot | undefined; queuedIntent: RequestIntent | undefined; + reconciliation: ReconciliationWindow | undefined; + reconciliationSuccesses: number; readonly state: NavigationState; readonly view: SlotRecord; } @@ -178,6 +197,7 @@ interface PhysicalCycle { interface PhysicalSlot { activeCycle: PhysicalCycle | undefined; definition: GoogletagReplacementDefinition | undefined; + domElement: object | undefined; lastResponseIdentifier: string | undefined; ownership: GptSlotOwnership; placementKeys: readonly string[]; @@ -190,6 +210,16 @@ interface PhysicalSlot { destroyAttempted: boolean; } +interface ReconciliationWindow { + debounceTimer: ReturnType | undefined; + deadlineTimer: ReturnType | undefined; + readonly deadlineAt: number; + firstPassFinished: boolean; + operation: GoogletagOperation | undefined; + readonly orphan: PhysicalSlot; + terminal: boolean; +} + interface RequestIntent { completionTimer: ReturnType | undefined; readonly input: SlotRequestInput; @@ -501,6 +531,80 @@ function placementKeysFor( return Object.freeze(keys); } +/** Capture a browser DOM boundary without installing an observer until service activation. */ +export function createBrowserSlotReconciliationBoundary( + documentTarget: Document, + Observer: typeof MutationObserver +): SlotReconciliationBoundary | undefined { + try { + const root = documentTarget.documentElement; + const windowTarget = documentTarget.defaultView; + if (!root || !windowTarget || typeof Observer !== 'function') return undefined; + const querySelectorAll = windowTarget.Document.prototype.querySelectorAll; + const contains = windowTarget.Node.prototype.contains; + const elementId = Object.getOwnPropertyDescriptor(windowTarget.Element.prototype, 'id')?.get; + if ( + typeof querySelectorAll !== 'function' || + typeof contains !== 'function' || + typeof elementId !== 'function' + ) { + return undefined; + } + const isConnected = (element: object): boolean => { + try { + return Reflect.apply(contains, root, [element]) === true; + } catch { + return false; + } + }; + return Object.freeze({ + isConnected, + observe: (callback: () => void): (() => void) => { + if (typeof callback !== 'function') throw new TypeError('reconciliation callback required'); + const observer = new Observer(() => callback()); + observer.observe(root, { childList: true, subtree: true }); + let active = true; + return (): void => { + if (!active) return; + active = false; + observer.disconnect(); + }; + }, + resolve: (elementIds: readonly string[]): SlotReconciliationResolution => { + if (!Array.isArray(elementIds) || elementIds.length === 0) { + return Object.freeze({ status: 'unresolved' }); + } + const elements = Reflect.apply(querySelectorAll, documentTarget, [ + '[id]', + ]) as NodeListOf; + let match: object | undefined; + let matchId: string | undefined; + for (let elementIndex = 0; elementIndex < elements.length; elementIndex += 1) { + const element = elements.item(elementIndex); + if (!element || !isConnected(element)) continue; + const id = Reflect.apply(elementId, element, []) as string; + let accepted = false; + for (let idIndex = 0; idIndex < elementIds.length; idIndex += 1) { + if (elementIds[idIndex] === id) { + accepted = true; + break; + } + } + if (!accepted) continue; + if (match && match !== element) return Object.freeze({ status: 'ambiguous' }); + match = element; + matchId = id; + } + return match && matchId !== undefined + ? Object.freeze({ status: 'unique', element: match, elementId: matchId }) + : Object.freeze({ status: 'unresolved' }); + }, + }); + } catch { + return undefined; + } +} + /** Construct the document-lifetime slot registry and physical GPT cycle service. */ export function createSlotService(options: SlotServiceOptions): SlotService { const navigationStates = new Map(); @@ -512,15 +616,98 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const placementQuarantine = new Map(); const quarantinedKeysByPhysical = new WeakMap(); const now = options.now ?? (() => performance.now()); + let reconciliationBoundary: SlotReconciliationBoundary | undefined; + let reconciliationObserve: SlotReconciliationBoundary['observe'] | undefined; + let reconciliationIsConnected: SlotReconciliationBoundary['isConnected'] | undefined; + let reconciliationResolve: SlotReconciliationBoundary['resolve'] | undefined; + try { + const candidate = options.reconciliation; + if ( + candidate && + typeof candidate.observe === 'function' && + typeof candidate.isConnected === 'function' && + typeof candidate.resolve === 'function' + ) { + reconciliationBoundary = candidate; + reconciliationObserve = candidate.observe; + reconciliationIsConnected = candidate.isConnected; + reconciliationResolve = candidate.resolve; + } + } catch { + reconciliationBoundary = undefined; + } let placementQuarantineSaturated = false; let placementQuarantinePoisoned = false; let saturationOwnerCount = 0; let disposed = false; let deferInvocations = false; let activation: GoogletagOperation | undefined; + let reconciliationActive = false; const subscriptionsByBinding = new WeakMap(); const bindingSubscriptions = new Set(); + const reconciliationElementIds = ( + record: InternalSlotRecord, + definition: GoogletagReplacementDefinition + ): readonly string[] => { + const values: string[] = [definition.elementId]; + for (let aliasIndex = 0; aliasIndex < record.view.domAliases.length; aliasIndex += 1) { + const alias = record.view.domAliases[aliasIndex]; + if (alias === undefined) continue; + let duplicate = false; + for (let valueIndex = 0; valueIndex < values.length; valueIndex += 1) { + if (values[valueIndex] === alias) { + duplicate = true; + break; + } + } + if (!duplicate) values[values.length] = alias; + } + return Object.freeze(values); + }; + + const resolveReconciliationElement = ( + record: InternalSlotRecord, + definition: GoogletagReplacementDefinition + ): SlotReconciliationResolution | undefined => { + if (!reconciliationBoundary || !reconciliationResolve) return undefined; + try { + const resolution = Reflect.apply(reconciliationResolve, reconciliationBoundary, [ + reconciliationElementIds(record, definition), + ]) as SlotReconciliationResolution; + if ( + !resolution || + (resolution.status !== 'unique' && + resolution.status !== 'unresolved' && + resolution.status !== 'ambiguous') + ) { + return undefined; + } + if ( + resolution.status === 'unique' && + (((typeof resolution.element !== 'object' || resolution.element === null) && + typeof resolution.element !== 'function') || + typeof resolution.elementId !== 'string' || + resolution.elementId.length === 0) + ) { + return undefined; + } + return resolution; + } catch { + return undefined; + } + }; + + const reconciliationElementConnected = (element: object | undefined): boolean => { + if (!reconciliationBoundary || !reconciliationIsConnected) return true; + if (!element) return false; + try { + return Reflect.apply(reconciliationIsConnected, reconciliationBoundary, [element]) === true; + } catch { + return true; + } + }; + const hasPlacementQuarantine = (keys: readonly string[]): boolean => { if (placementQuarantineSaturated || placementQuarantinePoisoned) return true; for (let index = 0; index < keys.length; index += 1) { @@ -650,7 +837,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const prepareReplacementCommit = ( record: InternalSlotRecord, oldPhysical: PhysicalSlot, - replacement: object + replacement: object, + definition = oldPhysical.definition, + domElement = oldPhysical.domElement ): GoogletagReplacementCommitAdmission => { if ( replacement === oldPhysical.slot || @@ -664,7 +853,8 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (existing) throw new GoogletagReplacementCandidateCollisionError(replacement); const physical: PhysicalSlot = { activeCycle: undefined, - definition: oldPhysical.definition, + definition, + domElement, destroyAttempted: false, lastResponseIdentifier: undefined, ownership: 'trusted_server', @@ -787,6 +977,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const orphan: PhysicalSlot = { activeCycle: undefined, definition: physical.definition, + domElement: undefined, destroyAttempted: true, lastResponseIdentifier: undefined, ownership: 'trusted_server', @@ -1176,11 +1367,338 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } }; + const clearReconciliationTimers = (window: ReconciliationWindow): void => { + if (window.debounceTimer !== undefined) clearTimeout(window.debounceTimer); + if (window.deadlineTimer !== undefined) clearTimeout(window.deadlineTimer); + window.debounceTimer = undefined; + window.deadlineTimer = undefined; + }; + + const cancelReconciliation = (record: InternalSlotRecord): void => { + const window = record.reconciliation; + if (!window || window.terminal) return; + window.terminal = true; + clearReconciliationTimers(window); + window.operation?.dispose(); + window.operation = undefined; + if (record.reconciliation === window) record.reconciliation = undefined; + }; + + const detachDestroyedReconciliationPhysical = (physical: PhysicalSlot): void => { + releasePhysicalPlacement(physical); + deleteSetValue(physicalSlots, physical); + if (weakMapValue(physicalByObject, physical.slot) === physical) { + deleteWeakMapValue(physicalByObject, physical.slot); + } + }; + + const settleReconciliationWork = ( + record: InternalSlotRecord, + physical: PhysicalSlot, + reason: SlotRequestFailure + ): void => { + const cycleIntent = physical.activeCycle?.intent; + if (cycleIntent && !cycleIntent.terminal) settle(cycleIntent, failed(reason)); + if (record.activeIntent) settle(record.activeIntent, failed(reason)); + if (record.queuedIntent) settle(record.queuedIntent, failed(reason)); + physical.activeCycle = undefined; + }; + + const retireFailedReconciliation = ( + record: InternalSlotRecord, + window: ReconciliationWindow, + reason: SlotRequestFailure, + transactionStarted: boolean, + oldSlotDestroyed: boolean + ): void => { + if (window.terminal) return; + window.terminal = true; + clearReconciliationTimers(window); + window.operation?.dispose(); + window.operation = undefined; + if (record.reconciliation === window) record.reconciliation = undefined; + const physical = window.orphan; + if (record.physical !== physical || physical.ownership !== 'trusted_server') return; + + settleReconciliationWork(record, physical, reason); + record.physical = undefined; + physical.record = undefined; + physical.state = 'retired'; + physical.quarantineReason = 'request'; + physical.destroyAttempted = true; + deleteSetValue(physicalSlots, physical); + if (oldSlotDestroyed) { + detachDestroyedReconciliationPhysical(physical); + return; + } + quarantinePhysicalPlacement(physical); + if (transactionStarted) return; + + let destroyOperation: GoogletagOperation | undefined; + try { + destroyOperation = options.googletag.run((gpt) => + gpt.transactionalReplace( + physical.slot, + undefined, + () => false, + () => { + throw new Error('destroy-only reconciliation cannot commit'); + } + ) + ); + void destroyOperation.result.then( + () => detachDestroyedReconciliationPhysical(physical), + () => undefined + ); + } catch { + destroyOperation?.dispose(); + } + }; + + const completeReconciliation = ( + record: InternalSlotRecord, + window: ReconciliationWindow + ): boolean => { + if (window.terminal || record.reconciliation !== window) return false; + const physical = record.physical; + if ( + !physical || + physical === window.orphan || + physical.ownership !== 'trusted_server' || + physical.record !== record || + record.state.disposed || + !record.state.owner.isCurrent() + ) { + return false; + } + window.terminal = true; + clearReconciliationTimers(window); + window.operation?.dispose(); + window.operation = undefined; + record.reconciliation = undefined; + record.reconciliationSuccesses += 1; + detachDestroyedReconciliationPhysical(window.orphan); + return true; + }; + + const startReconciliationReplacement = ( + record: InternalSlotRecord, + window: ReconciliationWindow, + resolution: Extract, + finalPass: boolean + ): void => { + const orphan = window.orphan; + const existingDefinition = orphan.definition; + if (!existingDefinition) { + retireFailedReconciliation(record, window, 'slot_unresolved', false, false); + return; + } + const definition = Object.freeze({ + adUnitPath: existingDefinition.adUnitPath, + elementId: resolution.elementId, + sizes: existingDefinition.sizes, + }); + let transactionStarted = false; + let operation: GoogletagOperation | undefined; + try { + operation = options.googletag.run((gpt) => { + transactionStarted = true; + orphan.state = 'retired'; + orphan.quarantineReason = 'request'; + orphan.destroyAttempted = true; + quarantinePhysicalPlacement(orphan); + return gpt.transactionalReplace( + orphan.slot, + definition, + () => + !window.terminal && + record.reconciliation === window && + !record.state.disposed && + record.state.owner.isCurrent() && + ((record.physical === orphan && orphan.ownership === 'trusted_server') || + (record.physical !== undefined && + record.physical !== orphan && + record.physical.record === record && + record.physical.ownership === 'trusted_server')), + (replacement) => + prepareReplacementCommit(record, orphan, replacement, definition, resolution.element) + ); + }); + window.operation = operation; + } catch { + retireFailedReconciliation(record, window, 'gpt_request_failed', transactionStarted, false); + return; + } + + if (record.physical !== orphan) { + completeReconciliation(record, window); + return; + } + if (finalPass && !transactionStarted) { + retireFailedReconciliation(record, window, 'slot_unresolved', transactionStarted, false); + return; + } + void operation.result.then( + (result) => { + if (window.terminal) return; + if (result.status === 'replaced' && completeReconciliation(record, window)) return; + retireFailedReconciliation(record, window, 'gpt_request_failed', true, true); + }, + (error: unknown) => { + const replacementError = error instanceof GoogletagReplacementError ? error : undefined; + retireFailedReconciliation( + record, + window, + 'gpt_request_failed', + transactionStarted, + replacementError?.oldSlotDestroyed === true + ); + } + ); + }; + + const runReconciliationPass = ( + record: InternalSlotRecord, + window: ReconciliationWindow, + finalPass: boolean + ): void => { + if ( + window.terminal || + record.reconciliation !== window || + record.physical !== window.orphan || + window.orphan.ownership !== 'trusted_server' || + record.state.disposed || + !record.state.owner.isCurrent() + ) { + cancelReconciliation(record); + return; + } + if (reconciliationElementConnected(window.orphan.domElement)) { + cancelReconciliation(record); + return; + } + if (window.operation) { + if (record.physical !== window.orphan) completeReconciliation(record, window); + else if (finalPass) { + retireFailedReconciliation(record, window, 'slot_unresolved', false, false); + } + return; + } + const definition = window.orphan.definition; + const resolution = definition && resolveReconciliationElement(record, definition); + if (resolution?.status === 'unique') { + startReconciliationReplacement(record, window, resolution, finalPass); + return; + } + if (finalPass) { + retireFailedReconciliation(record, window, 'slot_unresolved', false, false); + } else { + window.firstPassFinished = true; + } + }; + + const scheduleReconciliationDebounce = ( + record: InternalSlotRecord, + window: ReconciliationWindow + ): void => { + if (window.firstPassFinished || window.operation || window.terminal) return; + if (window.debounceTimer !== undefined) clearTimeout(window.debounceTimer); + window.debounceTimer = setTimeout(() => { + window.debounceTimer = undefined; + runReconciliationPass(record, window, false); + }, GPT_RECONCILIATION_DEBOUNCE_MS); + }; + + const openReconciliation = (record: InternalSlotRecord, physical: PhysicalSlot): void => { + if (record.reconciliationSuccesses >= MAX_SUCCESSFUL_RECONCILIATIONS) { + const instant: ReconciliationWindow = { + debounceTimer: undefined, + deadlineTimer: undefined, + deadlineAt: Number.NEGATIVE_INFINITY, + firstPassFinished: true, + operation: undefined, + orphan: physical, + terminal: false, + }; + record.reconciliation = instant; + retireFailedReconciliation(record, instant, 'reconciliation_capacity', false, false); + return; + } + const openedAt = now(); + if (!Number.isFinite(openedAt) || openedAt < 0) return; + const window: ReconciliationWindow = { + debounceTimer: undefined, + deadlineTimer: undefined, + deadlineAt: openedAt + GPT_RECONCILIATION_WINDOW_MS, + firstPassFinished: false, + operation: undefined, + orphan: physical, + terminal: false, + }; + record.reconciliation = window; + scheduleReconciliationDebounce(record, window); + window.deadlineTimer = setTimeout(() => { + window.deadlineTimer = undefined; + if (window.terminal) return; + const current = now(); + if (Number.isFinite(current) && current < window.deadlineAt) { + window.deadlineTimer = setTimeout( + () => runReconciliationPass(record, window, true), + Math.max(1, window.deadlineAt - current) + ); + return; + } + runReconciliationPass(record, window, true); + }, GPT_RECONCILIATION_WINDOW_MS); + }; + + const inspectNavigationDom = (state: NavigationState): void => { + if (state.disposed || !state.owner.isCurrent()) return; + const records = mapValueSnapshot(state.records); + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + const physical = record?.physical; + if (!record || !physical) continue; + if (physical.ownership !== 'trusted_server' || !physical.definition) { + cancelReconciliation(record); + continue; + } + if (reconciliationElementConnected(physical.domElement)) continue; + const existing = record.reconciliation; + if (!existing) openReconciliation(record, physical); + else if (existing.orphan === physical) scheduleReconciliationDebounce(record, existing); + else cancelReconciliation(record); + } + }; + + const installNavigationObserver = (state: NavigationState): boolean => { + if (!reconciliationBoundary || !reconciliationObserve) return true; + if (state.observerRelease) return true; + try { + const release = Reflect.apply(reconciliationObserve, reconciliationBoundary, [ + () => inspectNavigationDom(state), + ]) as () => void; + if (typeof release !== 'function') return false; + state.observerRelease = release; + return true; + } catch { + return false; + } + }; + const disposeNavigationState = (state: NavigationState): void => { if (state.disposed) return; state.disposed = true; + const observerRelease = state.observerRelease; + state.observerRelease = undefined; + try { + observerRelease?.(); + } catch { + // Logical observer ownership is already released. + } const records = mapValueSnapshot(state.records); for (const record of records) { + cancelReconciliation(record); const active = record.activeIntent; const queued = record.queuedIntent; if (active) settle(active, cancelled('navigation_disposed')); @@ -1205,6 +1723,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const state: NavigationState = { disposed: false, nextOrdinal: 0, + observerRelease: undefined, owner, records: new Map(), }; @@ -1212,6 +1731,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { try { owner.onDispose('slot-records', () => disposeNavigationState(state)); disposerInstalled = true; + if (reconciliationActive && !installNavigationObserver(state)) { + throw new Error('reconciliation observer failed'); + } if (!owner.isCurrent() || state.disposed) return undefined; setMapValue(navigationStates, owner.generation, state); if (!owner.isCurrent() || state.disposed) { @@ -1316,6 +1838,8 @@ export function createSlotService(options: SlotServiceOptions): SlotService { activeIntent: undefined, physical: undefined, queuedIntent: undefined, + reconciliation: undefined, + reconciliationSuccesses: 0, state, view, }; @@ -1401,6 +1925,12 @@ export function createSlotService(options: SlotServiceOptions): SlotService { ) { return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); } + const initialResolution = + ownership === 'trusted_server' && definition + ? resolveReconciliationElement(record, definition) + : undefined; + const domElement = + initialResolution?.status === 'unique' ? initialResolution.element : undefined; const slotObject = slot as object; let bindingPlacementKeys: readonly string[]; try { @@ -1434,6 +1964,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const previousRecord = existing.record; const previousOwnership = existing.ownership; const previousDefinition = existing.definition; + const previousDomElement = existing.domElement; const previousPlacementKeys = existing.placementKeys; try { if (!wasStrong) addSetValue(physicalSlots, existing); @@ -1442,14 +1973,17 @@ export function createSlotService(options: SlotServiceOptions): SlotService { existing.record = record; existing.ownership = ownership; existing.definition = definition; + existing.domElement = domElement; existing.placementKeys = bindingPlacementKeys; record.physical = existing; + if (ownership === 'publisher') cancelReconciliation(record); return Object.freeze({ ok: true }); } catch { if (record.physical === existing) record.physical = undefined; existing.record = previousRecord; existing.ownership = previousOwnership; existing.definition = previousDefinition; + existing.domElement = previousDomElement; existing.placementKeys = previousPlacementKeys; if (!wasStrong) deleteSetValue(physicalSlots, existing); return Object.freeze({ ok: false, reason: 'stale_owner' }); @@ -1461,6 +1995,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const physical: PhysicalSlot = { activeCycle: undefined, definition, + domElement, destroyAttempted: false, lastResponseIdentifier: undefined, ownership, @@ -1495,7 +2030,13 @@ export function createSlotService(options: SlotServiceOptions): SlotService { resolve = resolveResult; }); const placeholderRecord = - record ?? ({ activeIntent: undefined, queuedIntent: undefined } as InternalSlotRecord); + record ?? + ({ + activeIntent: undefined, + queuedIntent: undefined, + reconciliation: undefined, + reconciliationSuccesses: 0, + } as InternalSlotRecord); const intent: RequestIntent = { completionTimer: undefined, completionDeadlineAt: undefined, @@ -1849,6 +2390,28 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const service: SlotService = Object.freeze({ activate: (): GoogletagOperation => { if (activation) return activation; + if (!reconciliationActive) { + const states = mapValueSnapshot(navigationStates); + const installed: NavigationState[] = []; + for (let index = 0; index < states.length; index += 1) { + const state = states[index]; + if (!state || !installNavigationObserver(state)) { + for (let releaseIndex = installed.length - 1; releaseIndex >= 0; releaseIndex -= 1) { + const installedState = installed[releaseIndex]; + const release = installedState?.observerRelease; + if (installedState) installedState.observerRelease = undefined; + try { + release?.(); + } catch { + // Failed activation retains no observer ownership. + } + } + throw new Error('reconciliation observer failed'); + } + if (state.observerRelease) installed[installed.length] = state; + } + reconciliationActive = true; + } let subscriptions: BindingSubscriptionAdmission | undefined; const operation = options.googletag.run((gpt) => { if (disposed) return; 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 4ca50c798..d129af03a 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -65,7 +65,7 @@ function browserProjection() { }; } -function largeAdmProjection(admLengths: number[]) { +function largeAdmProjection(admLengths: number[]): BrowserAuctionProjectionV1 { return { version: 1, auction: { @@ -752,6 +752,49 @@ describe('auction/parseTrustedServerAuctionResponseV1', () => { }; } + function admResponse(admLengths: number[]) { + const projected = largeAdmProjection(admLengths); + const canonical: BrowserAuctionProjectionV1 = { + version: 1, + auction: projected.auction, + 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()); @@ -766,6 +809,31 @@ describe('auction/parseTrustedServerAuctionResponseV1', () => { ); }); + 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(parseBrowserAuctionProjectionV1(canonical) !== undefined).toBe(accepted); + expect(parseTrustedServerAuctionResponseV1(wire) !== undefined).toBe(accepted); + } + }); + it.each([ ['Object', Object.prototype], ['Array', Array.prototype], diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 5c859c2f6..13948ee30 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -12,8 +12,10 @@ 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'; @@ -208,6 +210,73 @@ function bindTrustedSlot(service: SlotService, navigation: NavigationSession, id 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()); @@ -440,6 +509,317 @@ describe('slot registry', () => { }); }); +describe('navigation-owned DOM reconciliation', () => { + afterEach(() => vi.useRealTimers()); + + 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(); + 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(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' + ); + + 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('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('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', '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') gpt.destroySlots.mockReturnValue(false); + 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('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); From 3ba8b30abb3df4255079cb3a513af458643c9779 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:53:00 -0700 Subject: [PATCH 079/194] Harden GPT slot reconciliation races --- .../lib/src/services/slots.ts | 26 ++++++++++- .../lib/test/services/slots.test.ts | 44 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 625c5ec54..49477db0f 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -561,7 +561,13 @@ export function createBrowserSlotReconciliationBoundary( isConnected, observe: (callback: () => void): (() => void) => { if (typeof callback !== 'function') throw new TypeError('reconciliation callback required'); - const observer = new Observer(() => callback()); + const observer = new Observer(() => { + try { + callback(); + } catch { + // DOM observation cannot escape the service boundary. + } + }); observer.observe(root, { childList: true, subtree: true }); let active = true; return (): void => { @@ -1477,7 +1483,17 @@ export function createSlotService(options: SlotServiceOptions): SlotService { window.operation = undefined; record.reconciliation = undefined; record.reconciliationSuccesses += 1; + const active = record.activeIntent; + if ( + active && + !active.terminal && + (active.requestStartedAt !== undefined || window.orphan.activeCycle?.intent === active) + ) { + settle(active, failed('gpt_request_failed')); + } + window.orphan.activeCycle = undefined; detachDestroyedReconciliationPhysical(window.orphan); + advanceQueued(record); return true; }; @@ -1659,7 +1675,12 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const record = records[index]; const physical = record?.physical; if (!record || !physical) continue; - if (physical.ownership !== 'trusted_server' || !physical.definition) { + if ( + physical.ownership !== 'trusted_server' || + physical.state !== 'live' || + physical.publisherIntentCount > 0 || + !physical.definition + ) { cancelReconciliation(record); continue; } @@ -2503,6 +2524,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const physical = weakMapValue(physicalByObject, slot); if (!physical) return false; const record = physical.record; + if (record) cancelReconciliation(record); const cycleIntent = physical.activeCycle?.intent; if (cycleIntent && !cycleIntent.terminal) settle(cycleIntent, failed('gpt_request_failed')); if (record?.activeIntent) settle(record.activeIntent, failed('gpt_request_failed')); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 13948ee30..2d7b2d124 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -578,6 +578,50 @@ describe('navigation-owned DOM reconciliation', () => { 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); From ed44185ed8dd6e3b3598d6f21f634ac25fb42543 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:55:13 -0700 Subject: [PATCH 080/194] Bound shared auction registration graphs --- .../lib/src/core/registry.ts | 163 ++++++++++++++---- .../lib/test/core/registry.test.ts | 25 +++ 2 files changed, 157 insertions(+), 31 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 0e701b973..b94789eb5 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -59,9 +59,27 @@ interface JsonMeasureFrame { 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; @@ -140,13 +158,20 @@ function snapshotJsonContainer(value: object): JsonContainerSnapshot | undefined } /** Copy JSON data without invoking accessors or retaining publisher-owned objects. */ -function copyJsonRecord(value: unknown): Readonly> | undefined { +function copyJsonRecord( + value: unknown, + completed = new WeakMap | unknown[]>(), + measurements?: WeakMap +): Readonly> | undefined { if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; - const rootSnapshot = snapshotJsonContainer(value); + 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([value]); - const completed = new WeakMap | unknown[]>(); const stack: JsonCloneFrame[] = [ { index: 0, output: root, snapshot: rootSnapshot, source: value }, ]; @@ -188,7 +213,8 @@ function copyJsonRecord(value: unknown): Readonly> | und }); continue; } - const childSnapshot = snapshotJsonContainer(entry.value); + 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, { @@ -315,29 +341,52 @@ 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 measureJsonBytes(value: unknown): number | undefined { +function measureJson( + value: unknown, + memo = new WeakMap() +): JsonMeasurement | undefined { const primitive = primitiveJsonBytes(value); - if (primitive !== undefined) return primitive; + 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 memo = new WeakMap(); const active = new Set([value]); const stack: JsonMeasureFrame[] = [ - { array: root.array, bytes: 2, entries: root.entries, index: 0, source: value }, + { + 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) { - memo.set(frame.source, frame.bytes); + 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 frame.bytes; - parent.bytes = boundedBytes(parent.bytes, frame.bytes); - if (parent.bytes > MAX_AUCTION_BODY_BYTES) return parent.bytes; + 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]; @@ -347,11 +396,10 @@ function measureJsonBytes(value: unknown): number | undefined { const prefix = (entryIndex === 0 ? 0 : 1) + (frame.array ? 0 : encodedJsonStringBytes(entry.key) + 1); frame.bytes = boundedBytes(frame.bytes, prefix); - if (frame.bytes > MAX_AUCTION_BODY_BYTES) return frame.bytes; + frame.structureEntries = boundedStructureEntries(frame.structureEntries, 1); const childPrimitive = primitiveJsonBytes(entry.value); if (childPrimitive !== undefined) { frame.bytes = boundedBytes(frame.bytes, childPrimitive); - if (frame.bytes > MAX_AUCTION_BODY_BYTES) return frame.bytes; continue; } if (typeof entry.value !== 'object' || entry.value === null || active.has(entry.value)) { @@ -359,8 +407,11 @@ function measureJsonBytes(value: unknown): number | undefined { } const completed = memo.get(entry.value); if (completed !== undefined) { - frame.bytes = boundedBytes(frame.bytes, completed); - if (frame.bytes > MAX_AUCTION_BODY_BYTES) return frame.bytes; + frame.bytes = boundedBytes(frame.bytes, completed.bytes); + frame.structureEntries = boundedStructureEntries( + frame.structureEntries, + completed.structureEntries - 1 + ); continue; } const child = snapshotJsonContainer(entry.value); @@ -372,6 +423,7 @@ function measureJsonBytes(value: unknown): number | undefined { entries: child.entries, index: 0, source: entry.value, + structureEntries: 1, }); } return undefined; @@ -407,7 +459,8 @@ export function prepareProgrammaticAdUnits( const occupied = snapshotKnownSlots(knownSlots); const seen = new Set(); - const prepared: ProgrammaticAdUnit[] = []; + const pending: PendingProgrammaticAdUnit[] = []; + const measurementMemo = new WeakMap(); for (let index = 0; index < units.length; index += 1) { const unit = ownDataRecord(units[index]); if ( @@ -459,11 +512,11 @@ export function prepareProgrammaticAdUnits( sizes.push(Object.freeze([dimensions[0] as number, dimensions[1] as number])); } - let bids: ProgrammaticAdUnit['bids']; + 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 copiedBids: Array[number]> = []; + const pendingBids: PendingProgrammaticBid[] = []; for (const rawBid of rawBids) { const bid = ownDataRecord(rawBid); if (!bid || (!exactKeys(bid, ['bidder']) && !exactKeys(bid, ['bidder', 'params']))) { @@ -476,19 +529,25 @@ export function prepareProgrammaticAdUnits( ) { throw new AdUnitRegistrationError('invalid_bidder', index); } - let params: Readonly> | undefined; + let params: object | undefined; if (bid.params !== undefined) { - params = copyJsonRecord(bid.params); - if (!params) throw new AdUnitRegistrationError('invalid_params', index); + 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 || measurement.structureEntries > MAX_JSON_STRUCTURE_ENTRIES) { + throw new AdUnitRegistrationError('invalid_params', index); + } + params = bid.params; } - copiedBids.push( + pendingBids.push( Object.freeze({ bidder: bid.bidder, ...(params === undefined ? {} : { params }) }) ); } - bids = Object.freeze(copiedBids); + bids = Object.freeze(pendingBids); } - prepared.push( + pending.push( Object.freeze({ code: unit.code, mediaTypes: Object.freeze({ @@ -499,16 +558,58 @@ export function prepareProgrammaticAdUnits( ); } - const unitsBytes = measureJsonBytes(prepared); - if (unitsBytes === undefined) throw new AdUnitRegistrationError('invalid_params'); - // `{"adUnits":` + encoded array + `,"config":{}}`. - if (boundedBytes(24, unitsBytes) > MAX_AUCTION_BODY_BYTES) { + 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 + prepared.length > MAX_ACTIVE_SLOT_RECORDS) { + if (occupied.size + pending.length > MAX_ACTIVE_SLOT_RECORDS) { throw new AdUnitRegistrationError('registry_capacity'); } - return Object.freeze(prepared); + + 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 { 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 ff44b062e..b6ab072bc 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -197,6 +197,31 @@ describe('registry', () => { ); }); + 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('serializes detached auction data without invoking inherited toJSON hooks', () => { const prepared = prepareProgrammaticAdUnits(unit(), new Set()); const context = Object.freeze({ segments: Object.freeze(['one']) }); From 445901753c2be55034d8b3646b865766360113cf Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:56:32 -0700 Subject: [PATCH 081/194] Retain failed GPT reconciliation identities --- .../lib/src/services/slots.ts | 70 +++++--- .../lib/test/services/slots.test.ts | 157 +++++++++++++++++- 2 files changed, 197 insertions(+), 30 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 49477db0f..05abfd982 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -910,6 +910,37 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }); }; + const quarantineReplacementOrphan = ( + source: PhysicalSlot, + orphanedSlot: object | undefined + ): void => { + if (!orphanedSlot || orphanedSlot === source.slot) return; + const orphan: PhysicalSlot = { + activeCycle: undefined, + definition: source.definition, + domElement: undefined, + destroyAttempted: true, + lastResponseIdentifier: undefined, + ownership: 'trusted_server', + placementKeys: source.placementKeys, + publisherIntentCount: 0, + quarantineReason: 'request', + record: undefined, + saturationOwner: false, + slot: orphanedSlot, + state: 'quarantined', + }; + try { + setWeakMapValue(physicalByObject, orphan.slot, orphan); + if (weakMapValue(physicalByObject, orphan.slot) !== orphan) { + throw new Error('orphan publication failed'); + } + quarantinePhysicalPlacement(orphan); + } catch { + placementQuarantinePoisoned = true; + } + }; + const recoverRequestTimeout = (record: InternalSlotRecord, physical: PhysicalSlot): void => { if (physical.destroyAttempted) { physical.state = 'quarantined'; @@ -979,32 +1010,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { ) { detachDestroyedOld(); } - if (replacementError?.orphanedSlot && replacementError.orphanedSlot !== physical.slot) { - const orphan: PhysicalSlot = { - activeCycle: undefined, - definition: physical.definition, - domElement: undefined, - destroyAttempted: true, - lastResponseIdentifier: undefined, - ownership: 'trusted_server', - placementKeys: physical.placementKeys, - publisherIntentCount: 0, - quarantineReason: 'request', - record: undefined, - saturationOwner: false, - slot: replacementError.orphanedSlot, - state: 'quarantined', - }; - try { - setWeakMapValue(physicalByObject, orphan.slot, orphan); - if (weakMapValue(physicalByObject, orphan.slot) !== orphan) { - throw new Error('orphan publication failed'); - } - quarantinePhysicalPlacement(orphan); - } catch { - placementQuarantinePoisoned = true; - } - } + quarantineReplacementOrphan(physical, replacementError?.orphanedSlot); failQueued(record, 'gpt_request_failed'); } ); @@ -1551,6 +1557,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return; } if (finalPass && !transactionStarted) { + void operation.result.then( + () => undefined, + () => undefined + ); retireFailedReconciliation(record, window, 'slot_unresolved', transactionStarted, false); return; } @@ -1562,12 +1572,18 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }, (error: unknown) => { const replacementError = error instanceof GoogletagReplacementError ? error : undefined; + const reusedOldIdentity = replacementError?.orphanedSlot === orphan.slot; + const oldSlotDestroyed = + replacementError?.oldSlotDestroyed === true && + replacementError.preserveOldQuarantine !== true && + !reusedOldIdentity; + quarantineReplacementOrphan(orphan, replacementError?.orphanedSlot); retireFailedReconciliation( record, window, 'gpt_request_failed', transactionStarted, - replacementError?.oldSlotDestroyed === true + oldSlotDestroyed ); } ); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 2d7b2d124..4d45887f8 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -512,6 +512,33 @@ describe('slot registry', () => { describe('navigation-owned DOM reconciliation', () => { afterEach(() => vi.useRealTimers()); + 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); @@ -719,14 +746,18 @@ describe('navigation-owned DOM reconciliation', () => { expect(gpt.destroySlots).toHaveBeenCalledTimes(1); }); - it.each(['destroy', 'define'] as const)( + 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') gpt.destroySlots.mockReturnValue(false); - else gpt.defineSlot.mockReturnValueOnce(undefined); + 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({ @@ -758,6 +789,126 @@ describe('navigation-owned DOM reconciliation', () => { } ); + 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); From c597c37daf9ed30045fafc40ab8a672f0a2212e2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:00:11 -0700 Subject: [PATCH 082/194] Harden Task 15 validation intrinsics --- .../src/core/contracts/auction_projection.ts | 102 ++++++++++++------ .../lib/src/core/contracts/request_ads.ts | 20 +++- .../lib/src/core/registry.ts | 30 ++++-- .../lib/test/core/auction.test.ts | 48 +++++++++ .../lib/test/core/registry.test.ts | 52 +++++++++ .../lib/test/core/request.test.ts | 50 +++++++++ 6 files changed, 255 insertions(+), 47 deletions(-) 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 index 9a23db9ee..25b7fd702 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts @@ -19,7 +19,10 @@ export const MAX_AUCTION_RESULTS = 256; const MAX_TARGETING_ENTRIES = 32; const MAX_ADM_BYTES = 512 * 1024; const MAX_URL_BYTES = 4096; +const reflectApplyIntrinsic = Reflect.apply; 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}$/; @@ -55,7 +58,9 @@ export function ownDataObject( return undefined; } const snapshot: Record = Object.create(null) as Record; - for (const name of names) { + 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; @@ -85,18 +90,20 @@ export function ownDataArray(value: unknown, maximum: number): unknown[] | undef } } -function validUnicodeScalars(value: string): boolean { +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 false; + if (!(next >= 0xdc00 && next <= 0xdfff)) return undefined; index += 1; } else if (code >= 0xdc00 && code <= 0xdfff) { - return false; + return undefined; } + scalars += 1; } - return true; + return scalars; } function hasAsciiControl(value: string): boolean { @@ -112,16 +119,21 @@ export function validBoundedString( maximumBytes: number, options: { allowControls?: boolean; maximumScalars?: number } = {} ): value is string { + if (typeof value !== 'string' || value.length === 0) return false; + const scalarCount = unicodeScalarCount(value); return ( - typeof value === 'string' && - value.length > 0 && - validUnicodeScalars(value) && + scalarCount !== undefined && (options.allowControls === true || !hasAsciiControl(value)) && - textEncoder.encode(value).length <= maximumBytes && - (options.maximumScalars === undefined || Array.from(value).length <= options.maximumScalars) + (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' && @@ -133,11 +145,11 @@ export function validDimension(value: unknown): value is number { } export function isAuctionCandidateIdV1(value: unknown): value is string { - return typeof value === 'string' && candidateIdPattern.test(value); + return typeof value === 'string' && matches(candidateIdPattern, value); } export function isAuctionProviderIdV1(value: unknown): value is string { - return typeof value === 'string' && providerPattern.test(value); + return typeof value === 'string' && matches(providerPattern, value); } function boundedJsonBytes(left: number, right: number, maximum: number): number { @@ -211,7 +223,8 @@ export function jsonUtf8ByteLength(value: unknown): number { const root = snapshotJsonForMeasurement(value); if (!root) return Number.POSITIVE_INFINITY; const memo = new WeakMap(); - const active = new Set([value]); + 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]; @@ -271,7 +284,7 @@ export function jsonUtf8ByteLength(value: unknown): number { /** Whether a value is one exact server-minted renderer reservation identity. */ export function isRendererReservationIdV1(value: unknown): value is string { - return typeof value === 'string' && reservationIdPattern.test(value); + return typeof value === 'string' && matches(reservationIdPattern, value); } /** Validate and copy one exact browser render-source contract. */ @@ -283,18 +296,30 @@ export function parseBidRenderSourceV1( if (!record || typeof record.type !== 'string') return undefined; if (record.type === 'aps') { - const keys = [ - 'type', - 'version', - 'accountId', - 'bidId', - ...(Object.prototype.hasOwnProperty.call(record, 'creativeId') ? ['creativeId'] : []), - 'tagType', - 'creativeUrl', - 'aaxResponse', - 'width', - 'height', - ]; + 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; @@ -345,7 +370,7 @@ export function parseBidRenderSourceV1( !source || source.version !== 1 || typeof source.cacheId !== 'string' || - !cacheIdPattern.test(source.cacheId) || + !matches(cacheIdPattern, source.cacheId) || !validBoundedString(source.fetchUrl, MAX_URL_BYTES) || !validDimension(source.width) || !validDimension(source.height) || @@ -403,14 +428,15 @@ export function parseBidRenderSourceV1( 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 (!auctionIdPattern.test(record.auctionId)) 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 (const raw of results) { + 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); @@ -456,12 +482,19 @@ function parseTargeting(value: unknown): Record | undefined { const entries = Object.entries(record); if (entries.length > MAX_TARGETING_ENTRIES) return undefined; const targeting: Record = {}; - for (const [key, entry] of entries.sort(([left], [right]) => - left < right ? -1 : left > right ? 1 : 0 - )) { + 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' || - !targetingKeyPattern.test(key) || + !matches(targetingKeyPattern, key) || !validBoundedString(entry, 160, { maximumScalars: 40 }) ) { return undefined; @@ -538,7 +571,8 @@ export function parseBrowserAuctionProjectionV1( const bids: BrowserAuctionBidV1[] = []; const candidateIds = new Set(); const reservationIds = new Set(); - for (const raw of rawBids) { + for (let index = 0; index < rawBids.length; index += 1) { + const raw = rawBids[index]; const bid = parseBrowserBid(raw, cachePolicy); if ( !bid || 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 index 59b751b33..470a99bb3 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts @@ -1,5 +1,10 @@ const REQUEST_ADS_DEFAULT_TIMEOUT_MS = 10_000; const REQUEST_ADS_MAX_SLOTS = 256; +const reflectApplyIntrinsic = Reflect.apply; +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 @@ -37,7 +42,10 @@ function ownDataOptions(value: unknown): Record | undefined { if (prototype !== Object.prototype && prototype !== null) return undefined; if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; const output: Record = Object.create(null) as Record; - for (const key of Object.getOwnPropertyNames(value)) { + const names = Object.getOwnPropertyNames(value); + 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; @@ -83,7 +91,7 @@ function ownDataSlots(value: unknown): readonly unknown[] | undefined { function readAbortSignal(signal: unknown): boolean | undefined { try { return typeof abortSignalAbortedGetter === 'function' - ? (Reflect.apply(abortSignalAbortedGetter, signal, []) as boolean) + ? (reflectApplyIntrinsic(abortSignalAbortedGetter, signal, []) as boolean) : undefined; } catch { return undefined; @@ -123,13 +131,15 @@ export function validateRequestAdsOptions(value: unknown): ValidatedRequestAdsOp if (rawSlots.length === 0) throw new RequestAdsInputError('empty_slots'); const seen = new Set(); const copy: string[] = []; - for (const slot of rawSlots) { + for (let index = 0; index < rawSlots.length; index += 1) { + const slot = rawSlots[index]; if ( typeof slot !== 'string' || slot.length === 0 || - new TextEncoder().encode(slot).byteLength > 256 || + (reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [slot]) as Uint8Array) + .byteLength > 256 || hasAsciiControl(slot) || - /[\uD800-\uDFFF]/u.test(slot) + reflectApplyIntrinsic(regExpTestIntrinsic, loneSurrogatePattern, [slot]) ) { throw new RequestAdsInputError('invalid_slots'); } diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index b94789eb5..1863078e4 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -87,7 +87,10 @@ function ownDataRecord(value: unknown): Record | undefined { if (prototype !== Object.prototype && prototype !== null) return undefined; if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; const output: Record = Object.create(null) as Record; - for (const key of Object.getOwnPropertyNames(value)) { + const names = Object.getOwnPropertyNames(value); + 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, { @@ -171,7 +174,8 @@ function copyJsonRecord( const rootSnapshot = measurements?.get(value)?.snapshot ?? snapshotJsonContainer(value); if (!rootSnapshot || rootSnapshot.array) return undefined; const root: Record = {}; - const active = new Set([value]); + const active = new Set(); + active.add(value); const stack: JsonCloneFrame[] = [ { index: 0, output: root, snapshot: rootSnapshot, source: value }, ]; @@ -246,7 +250,8 @@ function copyJsonForSerialization(value: object): object | undefined { const rootSnapshot = snapshotJsonContainer(value); if (!rootSnapshot) return undefined; const root = safeSerializationContainer(rootSnapshot.array); - const active = new Set([value]); + const active = new Set(); + active.add(value); const completed = new WeakMap | unknown[]>(); const stack: JsonCloneFrame[] = [ { index: 0, output: root, snapshot: rootSnapshot, source: value }, @@ -357,7 +362,8 @@ function measureJson( if (completedRoot) return completedRoot; const root = snapshotJsonContainer(value); if (!root) return undefined; - const active = new Set([value]); + const active = new Set(); + active.add(value); const stack: JsonMeasureFrame[] = [ { array: root.array, @@ -491,7 +497,8 @@ export function prepareProgrammaticAdUnits( throw new AdUnitRegistrationError('invalid_media_types', index); } const sizes: Array = []; - for (const rawSize of rawSizes) { + for (let sizeIndex = 0; sizeIndex < rawSizes.length; sizeIndex += 1) { + const rawSize = rawSizes[sizeIndex]; const dimensions = ownDataArray(rawSize, 2); if ( !dimensions || @@ -517,7 +524,8 @@ export function prepareProgrammaticAdUnits( const rawBids = ownDataArray(unit.bids, MAX_JSON_STRUCTURE_ENTRIES); if (!rawBids) throw new AdUnitRegistrationError('invalid_bids', index); const pendingBids: PendingProgrammaticBid[] = []; - for (const rawBid of rawBids) { + 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); @@ -525,7 +533,11 @@ export function prepareProgrammaticAdUnits( if ( typeof bid.bidder !== 'string' || bid.bidder.length === 0 || - textEncoder.encode(bid.bidder).byteLength > 64 + ( + reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [ + bid.bidder, + ]) as Uint8Array + ).byteLength > 64 ) { throw new AdUnitRegistrationError('invalid_bidder', index); } @@ -639,7 +651,9 @@ export function serializeAuctionRequestBody( const legacyRegistry = new Map(); export function addAdUnits(units: AdUnit | AdUnit[]): void { - for (const unit of toArray(units)) { + const normalized = toArray(units); + for (let index = 0; index < normalized.length; index += 1) { + const unit = normalized[index]; if (!unit?.code) continue; legacyRegistry.set(unit.code, { ...legacyRegistry.get(unit.code), ...unit }); } 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 d129af03a..8df251f24 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -657,6 +657,54 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { } }); + it('uses captured validation intrinsics after platform prototypes are poisoned', () => { + const valid = largeAdmProjection([16]); + const invalid = largeAdmProjection([16]); + invalid.bids[0]!.provider = '-invalid'; + const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); + const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); + const calls = { encode: 0, iterator: 0, test: 0 }; + let parsed: BrowserAuctionProjectionV1 | undefined; + let rejected: 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'); + }, + }); + try { + parsed = parseBrowserAuctionProjectionV1(valid); + rejected = parseBrowserAuctionProjectionV1(invalid); + } 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); + } + + expect(parsed).toBeDefined(); + expect(rejected).toBeUndefined(); + expect(calls).toEqual({ encode: 0, iterator: 0, test: 0 }); + }); + it('requires cache sources to match one frozen cache policy exactly', () => { const cacheId = 'f47447a0-b759-4f2f-9887-af458b79b570'; const policy = parseCacheFetchPolicyV1({ 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 b6ab072bc..68fcfa919 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -222,6 +222,58 @@ describe('registry', () => { expect(ownKeysCalls).toBeLessThanOrEqual(4); }); + it('uses captured validation intrinsics after platform prototypes are poisoned', () => { + const validCandidate = unit('poison-safe'); + const invalidCandidate = { ...unit('invalid-bidder'), bids: [{ bidder: '' }] }; + const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); + const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); + const calls = { encode: 0, iterator: 0, test: 0 }; + let prepared: ReturnType | undefined; + let invalidError: unknown; + 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'); + }, + }); + try { + prepared = prepareProgrammaticAdUnits(validCandidate, new Set()); + try { + prepareProgrammaticAdUnits(invalidCandidate, new Set()); + } catch (error) { + invalidError = 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); + } + + expect(prepared?.[0]?.code).toBe('poison-safe'); + expect(invalidError).toBeInstanceOf(AdUnitRegistrationError); + expect(invalidError).toMatchObject({ code: 'invalid_bidder', unitIndex: 0 }); + expect(calls).toEqual({ encode: 0, iterator: 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']) }); 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 48625dcf0..dc3d85c8f 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -93,6 +93,56 @@ describe('requestAds input contract', () => { }); expectInputError(() => validateRequestAdsOptions({ slots: ['slot\u007fid'] }), 'invalid_slots'); }); + + it('uses captured validation intrinsics after platform prototypes are poisoned', () => { + const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); + const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); + const calls = { encode: 0, iterator: 0, test: 0 }; + let validated: ReturnType | undefined; + let duplicateError: unknown; + 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'); + }, + }); + try { + validated = validateRequestAdsOptions({ slots: ['slot-one'], timeoutMs: 100 }); + try { + validateRequestAdsOptions({ slots: ['slot-one', 'slot-one'] }); + } catch (error) { + duplicateError = 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); + } + + expect(validated).toMatchObject({ slots: ['slot-one'], timeoutMs: 100 }); + expect(duplicateError).toBeInstanceOf(RequestAdsInputError); + expect(duplicateError).toMatchObject({ code: 'duplicate_slot' }); + expect(calls).toEqual({ encode: 0, iterator: 0, test: 0 }); + }); }); describe('request.requestAds', () => { From 4f242d387f32260f6f8a84f37b0dcccf0af2a293 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:01:24 -0700 Subject: [PATCH 083/194] Classify shared DAG budget overflow --- .../lib/src/core/registry.ts | 4 +-- .../lib/test/core/registry.test.ts | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 1863078e4..476c56101 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -547,9 +547,7 @@ export function prepareProgrammaticAdUnits( throw new AdUnitRegistrationError('invalid_params', index); } const measurement = measureJson(bid.params, measurementMemo); - if (!measurement || measurement.structureEntries > MAX_JSON_STRUCTURE_ENTRIES) { - throw new AdUnitRegistrationError('invalid_params', index); - } + if (!measurement) throw new AdUnitRegistrationError('invalid_params', index); params = bid.params; } pendingBids.push( 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 68fcfa919..b2c63c311 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -222,6 +222,35 @@ describe('registry', () => { 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('invalid-bidder'), bids: [{ bidder: '' }] }; From 679671fca8870002952f106f24b6f3abdfdb7de6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:03:05 -0700 Subject: [PATCH 084/194] Publish GPT winners transactionally --- .../lib/src/composition/browser.ts | 36 +- .../lib/src/integrations/gpt/module.ts | 333 ++++++++++++++++++ .../lib/src/services/slots.ts | 28 ++ .../lib/test/composition/browser.test.ts | 75 ++-- .../lib/test/integrations/gpt/module.test.ts | 274 ++++++++++++++ .../lib/test/services/slots.test.ts | 26 ++ 6 files changed, 745 insertions(+), 27 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 705d52282..25d7c239f 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -34,7 +34,13 @@ import { } from '../core/registry'; import { prepareAdmIframe } from '../core/render'; import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; -import { startGptSlotOperation, type GptSlotOperationInput } from '../integrations/gpt/module'; +import { + publishGptWinner, + startGptSlotOperation, + type GptSlotOperationInput, + type GptWinnerPublicationInput, + type GptWinnerPublicationResult, +} from '../integrations/gpt/module'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; import { createRuntimeSession } from '../kernel/sessions'; @@ -130,6 +136,13 @@ export interface BrowserRuntimeComposition extends BrowserComposition { readonly startGptSlotOperationForTest: ( input: Omit ) => SlotOperationCreationResult; + /** Publish one prospective server winner through the ordered GPT transaction in tests. */ + readonly publishGptWinnerForTest: ( + input: Omit< + GptWinnerPublicationInput, + 'googletag' | 'navigation' | 'pucBridge' | 'reservations' | 'slots' | 'targeting' + > + ) => Promise; } export interface BrowserCoreActivations { @@ -731,6 +744,27 @@ export function createTestBrowserRuntimeComposition( reservationServiceForTest: () => browserServices?.reservations, rendererNonceRegistryForTest: () => browserServices?.rendererNonces, pucBridgeForTest: () => browserServices?.pucBridge, + publishGptWinnerForTest: ( + input: Omit< + GptWinnerPublicationInput, + '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, + googletag: composition.adapters.googletag, + navigation, + pucBridge: services.pucBridge, + reservations: services.reservations, + slots: services.slots, + targeting: services.targeting, + }); + }, startGptSlotOperationForTest: ( input: Omit ): SlotOperationCreationResult => { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts index e709c8003..92fc4d466 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -3,14 +3,29 @@ import type { IntegrationPrepareContext, IntegrationRegistration, } from '../../kernel/integration_registry'; +import type { GoogletagAdapter, GoogletagFacade } from '../../adapters/googletag'; +import { + isAuctionCandidateIdV1, + isRendererReservationIdV1, +} from '../../core/contracts/auction_projection'; +import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../core/types'; +import type { NavigationSession } from '../../kernel/sessions'; import { createSlotOperation, + type CommittedRenderArtifact, type RenderAttempt, + type RenderFailureReason, type SlotOperationCreationResult, type SlotOperationOptions, } from '../../services/render'; import type { PucBridge, PucGamAttemptInput } from '../../services/puc_bridge'; +import type { ReservationService } from '../../services/reservations'; import type { SlotRequestOutcome, SlotService } from '../../services/slots'; +import type { + TargetingBoundary, + TargetingOwnership, + TargetingService, +} from '../../services/targeting'; import { installGptGuard, resetGuardState } from './script_guard'; @@ -40,6 +55,324 @@ export interface GptSlotOperationInput extends Omit; } +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 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; + if ( + !projection || + !Object.isFrozen(projection) || + !Object.isFrozen(bid) || + !Object.isFrozen(bid.renderSource) || + !Object.isFrozen(bid.targeting) || + !isAuctionCandidateIdV1(bid.candidateId) || + !isRendererReservationIdV1(bid.rendererReservationId) || + bid.slot !== input.attempt.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() + ) { + 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 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 +): readonly (readonly [string, string])[] | undefined { + try { + const names = Object.getOwnPropertyNames(bid.targeting).sort(); + if (names.length > 32 || Object.getOwnPropertySymbols(bid.targeting).length !== 0) { + 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 || key === 'hb_adid') return undefined; + const descriptor = Object.getOwnPropertyDescriptor(bid.targeting, key); + 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 value: Value | undefined; + let failure: unknown; + const operation = adapter.run((gpt) => { + try { + value = command(gpt); + return value; + } catch (error) { + failure = error; + throw error; + } finally { + completed = true; + } + }); + void operation.result.catch(() => undefined); + if (!completed) { + operation.dispose(); + throw new Error('GPT targeting operation is not synchronously available'); + } + if (failure !== undefined) 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 bound = (() => { + try { + return input.slots.isBoundGptSlot(input.navigation.generation, input.bid.slot, input.slot); + } catch { + return false; + } + })(); + if (!bound) { + disposeArtifact(); + return failAttempt('slot_unresolved'); + } + const entries = targetingEntries(input.bid); + 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 resourcesDisposed = false; + const disposeResources = (): void => { + if (resourcesDisposed) return; + resourcesDisposed = true; + 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(); + }; + const tombstone = (): void => { + try { + input.reservations.tombstone( + { + reservationId: input.bid.rendererReservationId, + slot: input.bid.slot, + navigationGeneration: input.navigation.generation, + attemptId: input.attempt.id, + }, + 'disposed' + ); + } catch { + // The failed publication is already terminal and cannot expose the id again. + } + }; + 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'); + } + 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; + } + } catch { + tombstone(); + 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 }), + 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); + requestStarted = true; + return handle; + }, + }, + }); + } catch { + tombstone(); + disposeResources(); + return failAttempt('gpt_request_failed'); + } + if (!operation.ok || !bridgeRegistered || !requestStarted) { + tombstone(); + disposeResources(); + return failAttempt('gpt_request_failed'); + } + return operation; +} + function settleFromSlotOutcome( attempt: RenderAttempt, bridge: GptSlotOperationInput['pucBridge'], diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 05abfd982..7c59f56fb 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -131,6 +131,11 @@ export interface SlotService { ) => GptSlotAdoptionResult; readonly dispose: () => void; readonly handleGptEvent: (type: GptEventType, event: unknown) => void; + readonly isBoundGptSlot: ( + navigationGeneration: object, + registeredSlotId: string, + slot: object + ) => boolean; readonly prepareProjectionSlots: ( owner: NavigationSession, slots: readonly string[] @@ -2487,6 +2492,29 @@ export function createSlotService(options: SlotServiceOptions): SlotService { activation?.dispose(); }, handleGptEvent, + isBoundGptSlot: ( + navigationGeneration: object, + registeredSlotId: string, + slot: object + ): boolean => { + try { + const state = mapValue(navigationStates, navigationGeneration); + const record = state ? mapValue(state.records, registeredSlotId) : undefined; + const physical = record?.physical; + return ( + !!state && + !state.disposed && + state.owner.isCurrent() && + !!physical && + physical.slot === slot && + physical.record === record && + physical.ownership === 'trusted_server' && + physical.state === 'live' + ); + } catch { + return false; + } + }, prepareProjectionSlots: ( owner: NavigationSession, slots: readonly string[] diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index fd7746ae5..0a7af0c7b 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -22,6 +22,7 @@ import { createTestBrowserRuntimeComposition, } from '../../src/composition/browser'; import { log as localLog } from '../../src/core/log'; +import type { BrowserAuctionBidV1 } from '../../src/core/types'; import { createGptIntegrationRegistration } from '../../src/integrations/gpt/module'; import { isGuardInstalled, resetGuardState } from '../../src/integrations/gpt/script_guard'; import { publicLog } from '../../src/kernel/fallback'; @@ -51,18 +52,29 @@ function fakeGoogletagAdapter( function synchronousGptAdapter() { const listeners = new Map void>>(); + const targeting = new WeakMap>(); const bindingToken = Object.freeze({}); const refresh = vi.fn(); const facade: GoogletagFacade = Object.freeze({ bindingToken: () => bindingToken, - clearTargeting: vi.fn(), + clearTargeting: vi.fn((slot: object, key?: string) => { + const values = targeting.get(slot); + if (key === undefined) values?.clear(); + else values?.delete(key); + }), display: vi.fn(), - getTargeting: vi.fn(() => []), + getTargeting: vi.fn((slot: object, key: string) => + Object.freeze([...(targeting.get(slot)?.get(key) ?? [])]) + ), observeTargeting: () => vi.fn(), refresh, serviceState: () => Object.freeze({ apiReady: true, initialLoadDisabled: false, pubadsReady: true }), - setTargeting: vi.fn(), + 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])); + }), slots: () => Object.freeze([]), subscribe: (eventType: string, listener: (event: unknown) => void) => { const registered = listeners.get(eventType) ?? new Set(); @@ -172,14 +184,39 @@ describe('browser composition', () => { 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: 'no_bid' as const })]), + results: Object.freeze([ + Object.freeze({ + slot: 'slot-one', + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), }), - bids: Object.freeze([]), + bids: Object.freeze([bid]), }); const composition = createTestBrowserRuntimeComposition( { @@ -243,13 +280,6 @@ describe('browser composition', () => { }; const ownerResult = batch.createRenderAttempt('slot-one'); if (!ownerResult.ok) throw new Error(ownerResult.reason); - const source = Object.freeze({ - type: 'adm' as const, - version: 1 as const, - adm: '
fictional fallback
', - width: 300, - height: 250, - }); const primaryResult = createRenderAttempt({ artifacts: artifacts as Parameters[0]['artifacts'], owner: ownerResult.value, @@ -258,18 +288,6 @@ describe('browser composition', () => { }); if (!primaryResult.ok) throw new Error(primaryResult.reason); const primary = primaryResult.value; - const reservationId = `r1_${'a'.repeat(22)}`; - const winnerContext = Object.freeze({ selectedCpm: 1 }); - expect( - reservations.registerRender({ - reservationId, - slot: primary.slot, - navigation, - attemptId: primary.id, - renderSource: source, - winnerContext, - }) - ).toMatchObject({ ok: true }); const physicalSlot = Object.freeze({}); const slotElement = document.createElement('div'); slotElement.id = 'slot-one'; @@ -292,10 +310,15 @@ describe('browser composition', () => { 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'); let fallback: RenderAttempt | undefined; - const operation = composition.startGptSlotOperationForTest({ + const operation = await composition.publishGptWinnerForTest({ artifact, attempt: primary, + bid: projectedBid, createFallback: (parentAttemptId) => { fallback = createAttempt(parentAttemptId); return Object.freeze({ ok: true as const, value: fallback }); @@ -303,7 +326,7 @@ describe('browser composition', () => { operation: 'refresh', owner: ownerResult.value, requestClass: 'primary', - reservationId, + slot: physicalSlot, }); expect(operation.ok).toBe(true); 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 index 234bd74ad..91c462822 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -2,9 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createGptIntegrationRegistration, + publishGptWinner, startGptSlotOperation, + type GptWinnerPublicationInput, type GptSlotOperationInput, } from '../../../src/integrations/gpt/module'; +import { createNoopGoogletagAdapter, type GoogletagFacade } from '../../../src/adapters/googletag'; import { isGuardInstalled, resetGuardState } from '../../../src/integrations/gpt/script_guard'; import { createTestNavigationIdentityIssuer } from '../../../src/kernel/identity'; import { @@ -21,6 +24,7 @@ import { } 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)}`; @@ -83,8 +87,10 @@ function createAttemptHarness() { artifact, createAttempt: (parentAttemptId: string): RenderAttempt => createAttemptWithOwner(parentAttemptId).attempt, + navigation: navigationResult.value, primary, primaryOwner: primaryCreated.owner, + reservations, runtime, }; } @@ -452,3 +458,271 @@ describe('transactional GPT integration module', () => { } ); }); + +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 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, + }), + ]), + }), + 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), + display: vi.fn(), + getTargeting: (target: object, key: string) => (target as typeof slot).getTargeting(key), + observeTargeting: () => { + order.push('observe'); + return vi.fn(); + }, + 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), + 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, + googletag, + navigation: harness.navigation, + operation: 'refresh', + owner: harness.primaryOwner, + 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', + 'target:hb_adid', + 'target:hb_bidder', + 'bridge', + 'request', + ]); + expect(publication.values).toEqual( + new Map([ + ['hb_adid', [RESERVATION_ID]], + ['hb_bidder', ['trusted']], + ]) + ); + publication.bridgeArtifact()?.dispose(); + expect(publication.values.size).toBe(0); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + 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', + 'target:hb_adid', + 'target:hb_bidder', + '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/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 4d45887f8..9acc2d007 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -425,6 +425,32 @@ describe('slot registry', () => { expect(service.resolveRegisteredSlot('one')).toBeUndefined(); }); + it('recognizes only the exact live Trusted Server GPT binding', () => { + 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(false); + + runtime.dispose(); + expect(service.isBoundGptSlot(navigation.generation, 'trusted', trustedSlot)).toBe(false); + }); + it('uses captured Set validation intrinsics on a hostile page', () => { const service = createSlotService({ googletag: createGptHarness().adapter }); const navigation = createNavigation(); From d8f98b00111c5757422d839a396bcafc93bf60e2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:05:52 -0700 Subject: [PATCH 085/194] Revalidate GPT winner publication ownership --- .../lib/src/integrations/gpt/module.ts | 80 ++++++++++++------- .../lib/test/integrations/gpt/module.test.ts | 73 +++++++++++++++++ 2 files changed, 124 insertions(+), 29 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts index 92fc4d466..23c4ace54 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -91,10 +91,10 @@ function currentProjectedWinner(input: GptWinnerPublicationInput): boolean { const bid = input.bid; if ( !projection || - !Object.isFrozen(projection) || - !Object.isFrozen(bid) || - !Object.isFrozen(bid.renderSource) || - !Object.isFrozen(bid.targeting) || + !objectIsFrozenIntrinsic(projection) || + !objectIsFrozenIntrinsic(bid) || + !objectIsFrozenIntrinsic(bid.renderSource) || + !objectIsFrozenIntrinsic(bid.targeting) || !isAuctionCandidateIdV1(bid.candidateId) || !isRendererReservationIdV1(bid.rendererReservationId) || bid.slot !== input.attempt.slot || @@ -144,8 +144,19 @@ function targetingEntries( bid: BrowserAuctionBidV1 ): readonly (readonly [string, string])[] | undefined { try { - const names = Object.getOwnPropertyNames(bid.targeting).sort(); - if (names.length > 32 || Object.getOwnPropertySymbols(bid.targeting).length !== 0) { + const unsortedNames = objectGetOwnPropertyNamesIntrinsic(bid.targeting); + const names: string[] = []; + for (let index = 0; index < unsortedNames.length; index += 1) { + const name = unsortedNames[index]; + if (name === undefined) return undefined; + let insertion = names.length; + while (insertion > 0 && (names[insertion - 1] as string) > name) insertion -= 1; + for (let move = names.length; move > insertion; move -= 1) { + names[move] = names[move - 1] as string; + } + names[insertion] = name; + } + if (names.length > 32 || objectGetOwnPropertySymbolsIntrinsic(bid.targeting).length !== 0) { return undefined; } const entries: Array = [ @@ -154,7 +165,7 @@ function targetingEntries( for (let index = 0; index < names.length; index += 1) { const key = names[index]; if (!key || key === 'hb_adid') return undefined; - const descriptor = Object.getOwnPropertyDescriptor(bid.targeting, key); + const descriptor = objectGetOwnPropertyDescriptorIntrinsic(bid.targeting, key); if ( !descriptor || !descriptor.enumerable || @@ -174,6 +185,7 @@ function targetingEntries( 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) => { @@ -181,6 +193,7 @@ function synchronousTargetingBoundary(adapter: GoogletagAdapter, slot: object): value = command(gpt); return value; } catch (error) { + failed = true; failure = error; throw error; } finally { @@ -192,7 +205,7 @@ function synchronousTargetingBoundary(adapter: GoogletagAdapter, slot: object): operation.dispose(); throw new Error('GPT targeting operation is not synchronously available'); } - if (failure !== undefined) throw failure; + if (failed) throw failure; return value as Value; }; return Object.freeze({ @@ -235,14 +248,14 @@ export async function publishGptWinner( disposeArtifact(); return failAttempt('winner_not_renderable'); } - const bound = (() => { + const isStillBound = (): boolean => { try { return input.slots.isBoundGptSlot(input.navigation.generation, input.bid.slot, input.slot); } catch { return false; } - })(); - if (!bound) { + }; + if (!isStillBound()) { disposeArtifact(); return failAttempt('slot_unresolved'); } @@ -273,10 +286,29 @@ export async function publishGptWinner( 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(); @@ -291,27 +323,16 @@ export async function publishGptWinner( } disposeArtifact(); }; - const tombstone = (): void => { - try { - input.reservations.tombstone( - { - reservationId: input.bid.rendererReservationId, - slot: input.bid.slot, - navigationGeneration: input.navigation.generation, - attemptId: input.attempt.id, - }, - 'disposed' - ); - } catch { - // The failed publication is already terminal and cannot expose the id again. - } - }; 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]; @@ -320,8 +341,11 @@ export async function publishGptWinner( if (!owner) throw new Error('targeting ownership unavailable'); owners[owners.length] = owner; } + if (!isStillBound()) { + disposeResources(); + return failAttempt('slot_unresolved'); + } } catch { - tombstone(); disposeResources(); return failAttempt('gpt_request_failed'); } @@ -361,12 +385,10 @@ export async function publishGptWinner( }, }); } catch { - tombstone(); disposeResources(); return failAttempt('gpt_request_failed'); } if (!operation.ok || !bridgeRegistered || !requestStarted) { - tombstone(); disposeResources(); return failAttempt('gpt_request_failed'); } 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 index 91c462822..878a3a4ac 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -614,8 +614,10 @@ describe('ordered GPT winner publication', () => { 'slot:validate', 'reservation', 'observe', + 'slot:validate', 'target:hb_adid', 'target:hb_bidder', + 'slot:validate', 'bridge', 'request', ]); @@ -627,10 +629,79 @@ describe('ordered GPT winner publication', () => { ); 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', + '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(() => { @@ -667,8 +738,10 @@ describe('ordered GPT winner publication', () => { 'slot:validate', 'reservation', 'observe', + 'slot:validate', 'target:hb_adid', 'target:hb_bidder', + 'slot:validate', 'bridge', 'request', ]); From 615352f6d6cc35850a6063c026767df92fd3c777 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:08:01 -0700 Subject: [PATCH 086/194] Latch GPT publication to physical slot --- .../lib/src/integrations/gpt/module.ts | 15 ++++++-- .../lib/src/services/slots.ts | 7 +++- .../lib/test/services/slots.test.ts | 36 +++++++++++++++++-- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts index 23c4ace54..4b1bfa27e 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -41,6 +41,8 @@ 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 GptIntegrationRuntime { readonly start: (config: unknown) => void; @@ -110,7 +112,8 @@ function currentProjectedWinner(input: GptWinnerPublicationInput): boolean { typeof input.artifact.dispose !== 'function' || typeof input.slot !== 'object' || input.slot === null || - !input.navigation.isCurrent() + !input.navigation.isCurrent() || + input.attempt.snapshot().outcome !== undefined ) { return false; } @@ -200,7 +203,10 @@ function synchronousTargetingBoundary(adapter: GoogletagAdapter, slot: object): completed = true; } }); - void operation.result.catch(() => undefined); + void reflectApplyIntrinsic(promiseThenIntrinsic, operation.result, [ + () => undefined, + () => undefined, + ]); if (!completed) { operation.dispose(); throw new Error('GPT targeting operation is not synchronously available'); @@ -341,6 +347,9 @@ export async function publishGptWinner( 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'); @@ -378,7 +387,7 @@ export async function publishGptWinner( reservationId: input.bid.rendererReservationId, slots: { request: (requestInput) => { - const handle = input.slots.request(requestInput); + const handle = input.slots.request({ ...requestInput, expectedSlot: input.slot }); requestStarted = true; return handle; }, diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 7c59f56fb..743aa6eee 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -95,6 +95,8 @@ export type SlotRequestOutcome = | Readonly<{ status: 'cancelled'; reason: 'navigation_disposed' | 'superseded' }>; export interface SlotRequestInput { + /** Exact physical identity latch for a cross-service publication transaction. */ + readonly expectedSlot?: object; readonly intentId: string; readonly navigationGeneration: object; readonly operation: 'display' | 'refresh'; @@ -2110,6 +2112,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { settle(intent, failed('slot_unresolved')); return handle; } + if (input.expectedSlot !== undefined && input.expectedSlot !== physical.slot) { + settle(intent, failed('slot_unresolved')); + return handle; + } if (physical.state === 'retired' || physical.quarantineReason === 'request') { settle(intent, failed('gpt_request_failed')); return handle; @@ -2508,7 +2514,6 @@ export function createSlotService(options: SlotServiceOptions): SlotService { !!physical && physical.slot === slot && physical.record === record && - physical.ownership === 'trusted_server' && physical.state === 'live' ); } catch { diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 9acc2d007..b4a583829 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -425,7 +425,39 @@ describe('slot registry', () => { expect(service.resolveRegisteredSlot('one')).toBeUndefined(); }); - it('recognizes only the exact live Trusted Server GPT binding', () => { + 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'); @@ -445,7 +477,7 @@ describe('slot registry', () => { slot: publisherSlot, }) ).toEqual({ ok: true }); - expect(service.isBoundGptSlot(navigation.generation, 'publisher', publisherSlot)).toBe(false); + expect(service.isBoundGptSlot(navigation.generation, 'publisher', publisherSlot)).toBe(true); runtime.dispose(); expect(service.isBoundGptSlot(navigation.generation, 'trusted', trustedSlot)).toBe(false); From f3984e585573eb8629d195bad85ba2955b041b6d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:11:30 -0700 Subject: [PATCH 087/194] Harden exact-key validation helpers --- .../src/core/contracts/auction_projection.ts | 35 ++++++++++++++----- .../lib/src/core/contracts/request_ads.ts | 19 ++++++---- .../lib/src/core/registry.ts | 30 +++++++++++++--- .../lib/test/core/auction.test.ts | 26 +++++++++++--- .../lib/test/core/registry.test.ts | 27 +++++++++++--- .../lib/test/core/request.test.ts | 33 +++++++++++++---- 6 files changed, 135 insertions(+), 35 deletions(-) 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 index 25b7fd702..b2a8bfbd1 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts @@ -20,6 +20,8 @@ const MAX_TARGETING_ENTRIES = 32; 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; @@ -42,6 +44,13 @@ const auctionFailureReasons = new Set([ '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[] @@ -50,12 +59,15 @@ export function ownDataObject( 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 ( - expectedKeys && - (names.length !== expectedKeys.length || expectedKeys.some((key) => !names.includes(key))) - ) { - 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) { @@ -76,8 +88,10 @@ export function ownDataArray(value: unknown, maximum: number): unknown[] | undef if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; if (value.length > maximum || Object.getOwnPropertySymbols(value).length !== 0) return undefined; - const names = Object.getOwnPropertyNames(value); - if (names.length !== value.length + 1 || !names.includes('length')) 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)); @@ -211,7 +225,10 @@ function snapshotJsonForMeasurement(value: object): JsonMeasureSnapshot | undefi array, entries: array ? values!.map((entry, index) => ({ key: String(index), value: entry })) - : Object.keys(record!).map((key) => ({ key, value: record![key] })), + : (reflectApplyIntrinsic(objectKeysIntrinsic, Object, [record]) as string[]).map((key) => ({ + key, + value: record![key], + })), }; } 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 index 470a99bb3..0258dbe14 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts @@ -1,6 +1,8 @@ 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; @@ -42,7 +44,9 @@ function ownDataOptions(value: unknown): Record | undefined { 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 = Object.getOwnPropertyNames(value); + 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; @@ -72,7 +76,9 @@ function ownDataSlots(value: unknown): readonly unknown[] | undefined { !Number.isSafeInteger(length.value) || length.value < 0 || length.value > REQUEST_ADS_MAX_SLOTS || - Object.getOwnPropertyNames(value).length !== length.value + 1 + (reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [value]) as string[]) + .length !== + length.value + 1 ) { return undefined; } @@ -117,10 +123,11 @@ export function validateRequestAdsOptions(value: unknown): ValidatedRequestAdsOp }); } const options = ownDataOptions(value); - if ( - !options || - !Object.keys(options).every((key) => key === 'slots' || key === 'timeoutMs' || key === 'signal') - ) { + 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'); } diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 476c56101..3a512eb48 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -12,6 +12,8 @@ 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; @@ -87,7 +89,9 @@ function ownDataRecord(value: unknown): Record | undefined { 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 = Object.getOwnPropertyNames(value); + 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; @@ -122,7 +126,9 @@ function ownDataArray(value: unknown, maximum: number): readonly unknown[] | und !Number.isSafeInteger(length.value) || length.value < 0 || length.value > maximum || - Object.getOwnPropertyNames(value).length !== length.value + 1 + (reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [value]) as string[]) + .length !== + length.value + 1 ) { return undefined; } @@ -139,8 +145,20 @@ function ownDataArray(value: unknown, maximum: number): readonly unknown[] | und } function exactKeys(record: Record, keys: readonly string[]): boolean { - const actual = Object.keys(record); - return actual.length === keys.length && actual.every((key) => keys.includes(key)); + 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 { @@ -156,7 +174,9 @@ function snapshotJsonContainer(value: object): JsonContainerSnapshot | undefined if (!array && !record) return undefined; const entries = array ? values!.map((entry, index) => Object.freeze({ key: String(index), value: entry })) - : Object.keys(record!).map((key) => Object.freeze({ key, value: record![key] })); + : (reflectApplyIntrinsic(objectKeysIntrinsic, Object, [record]) as string[]).map((key) => + Object.freeze({ key, value: record![key] }) + ); return Object.freeze({ array, entries: Object.freeze(entries) }); } 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 8df251f24..1287a3e72 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -659,12 +659,13 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { it('uses captured validation intrinsics after platform prototypes are poisoned', () => { const valid = largeAdmProjection([16]); - const invalid = largeAdmProjection([16]); - invalid.bids[0]!.provider = '-invalid'; + const invalid = { ...largeAdmProjection([16]), unknown: true }; const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const everyDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'every'); + const includesDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'includes'); const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); - const calls = { encode: 0, iterator: 0, test: 0 }; + const calls = { encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }; let parsed: BrowserAuctionProjectionV1 | undefined; let rejected: BrowserAuctionProjectionV1 | undefined; Object.defineProperty(Array.prototype, Symbol.iterator, { @@ -688,6 +689,20 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { 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'); + }, + }); try { parsed = parseBrowserAuctionProjectionV1(valid); rejected = parseBrowserAuctionProjectionV1(invalid); @@ -698,11 +713,14 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { 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); } expect(parsed).toBeDefined(); expect(rejected).toBeUndefined(); - expect(calls).toEqual({ encode: 0, iterator: 0, test: 0 }); + expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }); }); it('requires cache sources to match one frozen cache policy exactly', () => { 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 b2c63c311..2835ea05c 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -253,11 +253,13 @@ describe('registry', () => { it('uses captured validation intrinsics after platform prototypes are poisoned', () => { const validCandidate = unit('poison-safe'); - const invalidCandidate = { ...unit('invalid-bidder'), bids: [{ bidder: '' }] }; + const invalidCandidate = { ...unit('unknown-key'), unknown: true }; const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const everyDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'every'); + const includesDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'includes'); const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); - const calls = { encode: 0, iterator: 0, test: 0 }; + const calls = { encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }; let prepared: ReturnType | undefined; let invalidError: unknown; Object.defineProperty(Array.prototype, Symbol.iterator, { @@ -281,6 +283,20 @@ describe('registry', () => { 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'); + }, + }); try { prepared = prepareProgrammaticAdUnits(validCandidate, new Set()); try { @@ -295,12 +311,15 @@ describe('registry', () => { 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); } expect(prepared?.[0]?.code).toBe('poison-safe'); expect(invalidError).toBeInstanceOf(AdUnitRegistrationError); - expect(invalidError).toMatchObject({ code: 'invalid_bidder', unitIndex: 0 }); - expect(calls).toEqual({ encode: 0, iterator: 0, test: 0 }); + expect(invalidError).toMatchObject({ code: 'invalid_unit', unitIndex: 0 }); + expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }); }); it('serializes detached auction data without invoking inherited toJSON hooks', () => { 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 dc3d85c8f..5b69140d3 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -96,11 +96,13 @@ describe('requestAds input contract', () => { it('uses captured validation intrinsics after platform prototypes are poisoned', () => { const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const everyDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'every'); + const includesDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'includes'); const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); - const calls = { encode: 0, iterator: 0, test: 0 }; + const calls = { encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }; let validated: ReturnType | undefined; - let duplicateError: unknown; + let unknownKeyError: unknown; Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, value: () => { @@ -122,12 +124,26 @@ describe('requestAds input contract', () => { 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'); + }, + }); try { validated = validateRequestAdsOptions({ slots: ['slot-one'], timeoutMs: 100 }); try { - validateRequestAdsOptions({ slots: ['slot-one', 'slot-one'] }); + validateRequestAdsOptions({ unknown: true }); } catch (error) { - duplicateError = error; + unknownKeyError = error; } } finally { if (iteratorDescriptor) { @@ -136,12 +152,15 @@ describe('requestAds input contract', () => { 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); } expect(validated).toMatchObject({ slots: ['slot-one'], timeoutMs: 100 }); - expect(duplicateError).toBeInstanceOf(RequestAdsInputError); - expect(duplicateError).toMatchObject({ code: 'duplicate_slot' }); - expect(calls).toEqual({ encode: 0, iterator: 0, test: 0 }); + expect(unknownKeyError).toBeInstanceOf(RequestAdsInputError); + expect(unknownKeyError).toMatchObject({ code: 'invalid_options' }); + expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }); }); }); From 87144b65d588a232a8ee8f7892b7217c773fe42f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:18:13 -0700 Subject: [PATCH 088/194] Remove ambient validation predicates --- .../src/core/contracts/auction_projection.ts | 20 ++++++--------- .../lib/src/core/registry.ts | 21 +++++++++------- .../lib/test/core/auction.test.ts | 18 +++++++++++-- .../lib/test/core/registry.test.ts | 25 +++++++++++++++++-- .../lib/test/core/request.test.ts | 13 ++++++++-- 5 files changed, 70 insertions(+), 27 deletions(-) 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 index b2a8bfbd1..1c29574d2 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts @@ -603,19 +603,15 @@ export function parseBrowserAuctionProjectionV1( bids.push(bid); } - const winners = auction.results.filter( - (result): result is Extract => - result.outcome === 'winner' - ); - if ( - winners.length !== bids.length || - winners.some( - (winner, index) => - bids[index]?.candidateId !== winner.candidateId || bids[index]?.slot !== winner.slot - ) - ) { - return undefined; + 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, bids }; if (jsonUtf8ByteLength(projection) > MAX_BROWSER_AUCTION_PROJECTION_BYTES) { diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 3a512eb48..ec8c1e465 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -166,6 +166,12 @@ function jsonPrimitive(value: unknown): null | boolean | number | string | undef 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; @@ -520,23 +526,20 @@ export function prepareProgrammaticAdUnits( 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 || - dimensions.some( - (dimension) => - typeof dimension !== 'number' || - !Number.isFinite(dimension) || - !Number.isInteger(dimension) || - dimension <= 0 - ) + !validPositiveInteger(width) || + !validPositiveInteger(height) ) { throw new AdUnitRegistrationError('invalid_dimensions', index); } - if (dimensions.some((dimension) => (dimension as number) > 4_096)) { + if (width > 4_096 || height > 4_096) { throw new AdUnitRegistrationError('dimensions_out_of_range', index); } - sizes.push(Object.freeze([dimensions[0] as number, dimensions[1] as number])); + sizes.push(Object.freeze([width, height])); } let bids: readonly PendingProgrammaticBid[] | undefined; 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 1287a3e72..173e35a01 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -660,14 +660,18 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { 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, test: 0 }; + 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: () => { @@ -703,9 +707,17 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { 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); @@ -716,11 +728,13 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { 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(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }); + 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', () => { 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 2835ea05c..7fb1ed865 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -254,14 +254,20 @@ describe('registry', () => { 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, test: 0 }; + 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: () => { @@ -297,6 +303,13 @@ describe('registry', () => { 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 { @@ -304,6 +317,11 @@ describe('registry', () => { } catch (error) { invalidError = error; } + try { + prepareProgrammaticAdUnits(invalidDimensions, new Set()); + } catch (error) { + invalidDimensionsError = error; + } } finally { if (iteratorDescriptor) { Object.defineProperty(Array.prototype, Symbol.iterator, iteratorDescriptor); @@ -314,12 +332,15 @@ describe('registry', () => { 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(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, test: 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', () => { 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 5b69140d3..8a558d8db 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -98,9 +98,10 @@ describe('requestAds input contract', () => { 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, test: 0 }; + const calls = { encode: 0, every: 0, includes: 0, iterator: 0, some: 0, test: 0 }; let validated: ReturnType | undefined; let unknownKeyError: unknown; Object.defineProperty(Array.prototype, Symbol.iterator, { @@ -138,6 +139,13 @@ describe('requestAds input contract', () => { throw new Error('poisoned array includes'); }, }); + Object.defineProperty(Array.prototype, 'some', { + configurable: true, + value: () => { + calls.some += 1; + throw new Error('poisoned array some'); + }, + }); try { validated = validateRequestAdsOptions({ slots: ['slot-one'], timeoutMs: 100 }); try { @@ -155,12 +163,13 @@ describe('requestAds input contract', () => { 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(validated).toMatchObject({ slots: ['slot-one'], timeoutMs: 100 }); expect(unknownKeyError).toBeInstanceOf(RequestAdsInputError); expect(unknownKeyError).toMatchObject({ code: 'invalid_options' }); - expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }); + expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, some: 0, test: 0 }); }); }); From 3e02edcaadc2ab5ec1c7eec9e0b0f39d2117ec6e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:25:48 -0700 Subject: [PATCH 089/194] Stamp the external Prebid artifact --- .../lib/build-prebid-external.mjs | 149 +++++++++++----- .../lib/test/build-prebid-external.test.mjs | 38 +++- .../test/prebid-artifact-integration.test.mjs | 164 +++++++++++++++++- 3 files changed, 292 insertions(+), 59 deletions(-) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index 0718bdeee..128335ac0 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -34,6 +34,9 @@ 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'; export function parseArgs(argv) { const options = new Map(); @@ -138,8 +141,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`); @@ -160,10 +168,19 @@ 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.localeCompare(right.code) || left.moduleStem.localeCompare(right.moduleStem) + ), + }; } function generateAdapterImports(adapterNames, adaptersFile) { @@ -213,7 +230,13 @@ 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.localeCompare(right.moduleName)); } function createTemporaryModulePaths() { @@ -228,50 +251,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'); @@ -286,7 +279,39 @@ 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;i256||(previous!==undefined&&previous>=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)||!__tsSortedStrings(sources,64))return false;for(var k=0;k 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/test/build-prebid-external.test.mjs b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs index f22717f79..520723351 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,6 +8,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { + ARTIFACT_RELEASE_SENTINEL, deriveBundleMetadata, main, parseArgs, @@ -70,10 +71,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(manifest.artifactReleaseId); + expect(bundle).not.toContain(ARTIFACT_RELEASE_SENTINEL); + expect(bundle).not.toContain('__tsjs_prebid_bundle'); + expect(bundle).not.toContain('__tsjsPrebidShimInstalled'); 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 }); } 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 6582d9089..aaf2e5091 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 @@ -26,6 +26,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 +39,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({ @@ -88,6 +91,117 @@ describe('tsjs-prebid shim artifact', () => { }); describe('external bundle + served shim evaluated together', () => { + 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', + }); + 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.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(); + }); + + 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.pbjs = { que: [], cmd: [] };'); + 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)});` + ); + 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).toBeUndefined(); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__conflictingStamp); + expect(warn).toHaveBeenCalledTimes(1); + 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('populates the public API, installs the shim exactly once, and routes an /auction request', async () => { const dom = new JSDOM('', { url: 'https://pub.example.com/article', @@ -134,13 +248,45 @@ describe('external bundle + served shim evaluated together', () => { 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).toBeUndefined(); + expect(pageWindow.__tsjsPrebidShimInstalled).toBeUndefined(); + const artifactDescriptor = Object.getOwnPropertyDescriptor( + pageWindow.pbjs, + '__trustedServerArtifactV1' + ); + expect(artifactDescriptor).toMatchObject({ + enumerable: false, + writable: false, + configurable: false, + }); + expect(artifactDescriptor.value).toEqual( + expect.objectContaining({ + abi: 1, + artifactReleaseId: artifactManifest.artifactReleaseId, + prebidVersion: '10.26.0', + }) + ); + expect([...artifactDescriptor.value.bidderCodes]).toEqual(['adf', 'adform', 'adformOpenRTB']); + expect([...artifactDescriptor.value.bidderAliases]).toEqual([ + { code: 'adform', moduleStem: 'adf' }, + { code: 'adformOpenRTB', moduleStem: 'adf' }, + ]); + expect([...artifactDescriptor.value.userIdModules]).toEqual([ + { + moduleName: 'sharedIdSystem', + configNames: ['pubCommonId', 'sharedId'], + eidSources: ['pubcid.org'], + }, ]); - expect([...pageWindow.__tsjs_prebid_bundle.userIdModules]).toEqual(['sharedIdSystem']); + expect(Object.isFrozen(artifactDescriptor.value)).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.moduleStems)).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.bidderCodes)).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.bidderAliases)).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.bidderAliases[0])).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.userIdModules)).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0])).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0].configNames)).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0].eidSources)).toBe(true); // Count trustedServer registrations across repeated shim evaluations. const originalRegisterBidAdapter = pageWindow.pbjs.registerBidAdapter.bind(pageWindow.pbjs); From b9abc2e614da28e5381441971f8475ae7f8f3ad1 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:29:25 -0700 Subject: [PATCH 090/194] Harden external Prebid artifact binding --- .../lib/build-prebid-external.mjs | 15 ++++++++---- .../lib/src/adapters/prebid.ts | 23 +++++++++++-------- .../lib/test/adapters/prebid.test.ts | 10 ++++---- .../lib/test/build-prebid-external.test.mjs | 10 ++++++++ .../test/prebid-artifact-integration.test.mjs | 4 ++++ 5 files changed, 44 insertions(+), 18 deletions(-) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index 128335ac0..1aaebb606 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -69,10 +69,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) { @@ -178,7 +182,8 @@ export function readAdapterMetadata(adapterNames) { bidderCodes: [...bidderCodes].sort(), bidderAliases: bidderAliases.sort( (left, right) => - left.code.localeCompare(right.code) || left.moduleStem.localeCompare(right.moduleStem) + (left.code < right.code ? -1 : left.code > right.code ? 1 : 0) || + (left.moduleStem < right.moduleStem ? -1 : left.moduleStem > right.moduleStem ? 1 : 0) ), }; } @@ -236,7 +241,9 @@ function generateUserIdImports(requestedModules, userIdsFile) { configNames: [...new Set(entry.configNames)].sort(), eidSources: [...new Set(entry.eidSources.map((source) => source.toLowerCase()))].sort(), })) - .sort((left, right) => left.moduleName.localeCompare(right.moduleName)); + .sort((left, right) => + left.moduleName < right.moduleName ? -1 : left.moduleName > right.moduleName ? 1 : 0 + ); } function createTemporaryModulePaths() { @@ -305,7 +312,7 @@ function renderExternalWrapper(bundleCode, stamp) { `var __tsExistingWindow=window;var __tsExisting=__tsExistingWindow.pbjs;var __tsExistingDescriptor;try{__tsExistingDescriptor=__tsExisting&&Object.getOwnPropertyDescriptor(__tsExisting,"${ARTIFACT_PROPERTY}");}catch(_){__tsExistingDescriptor=undefined;}`, 'if(__tsExistingDescriptor&&Object.prototype.hasOwnProperty.call(__tsExistingDescriptor,"value")&&__tsExistingDescriptor.enumerable===false&&__tsExistingDescriptor.writable===false&&__tsExistingDescriptor.configurable===false&&__tsValidStamp(__tsExistingDescriptor.value)){if(__tsEqual(__tsExistingDescriptor.value,__tsStamp))return;__tsWarn();return;}', bundleCode, - `var __tsPbjs=window.pbjs;var __tsRequired=["addAdUnits","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids"];var __tsReady=!!__tsPbjs;for(var __tsIndex=0;__tsReady&&__tsIndex<__tsRequired.length;__tsIndex+=1)__tsReady=typeof __tsPbjs[__tsRequired[__tsIndex]]==="function";if(__tsReady){var __tsAfter;try{__tsAfter=Object.getOwnPropertyDescriptor(__tsPbjs,"${ARTIFACT_PROPERTY}");}catch(_){__tsAfter=undefined;}if(!__tsAfter){try{Object.defineProperty(__tsPbjs,"${ARTIFACT_PROPERTY}",{value:__tsStamp,enumerable:false,writable:false,configurable:false});}catch(_){__tsWarn();}}else if(!Object.prototype.hasOwnProperty.call(__tsAfter,"value")||!__tsEqual(__tsAfter.value,__tsStamp)){__tsWarn();}}`, + `var __tsPbjs=window.pbjs;var __tsRequired=["addAdUnits","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids"];var __tsReady=!!__tsPbjs;for(var __tsIndex=0;__tsReady&&__tsIndex<__tsRequired.length;__tsIndex+=1)__tsReady=typeof __tsPbjs[__tsRequired[__tsIndex]]==="function";if(__tsReady){var __tsAfter;var __tsInherited=false;try{__tsAfter=Object.getOwnPropertyDescriptor(__tsPbjs,"${ARTIFACT_PROPERTY}");__tsInherited=!__tsAfter&&Reflect.has(__tsPbjs,"${ARTIFACT_PROPERTY}");}catch(_){__tsAfter=undefined;__tsInherited=true;}if(!__tsAfter&&!__tsInherited){try{Object.defineProperty(__tsPbjs,"${ARTIFACT_PROPERTY}",{value:__tsStamp,enumerable:false,writable:false,configurable:false});}catch(_){__tsWarn();}}else if(__tsInherited||!Object.prototype.hasOwnProperty.call(__tsAfter,"value")||!__tsEqual(__tsAfter.value,__tsStamp)){__tsWarn();}}`, '})();', '', ].join('\n'); diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index 07e87ac50..a0d204e27 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -67,9 +67,9 @@ export interface PrebidArtifactRequirements { /** The small Prebid surface exposed to an accepted operation. */ export interface PrebidFacade { addAdUnits(adUnits: readonly unknown[]): unknown; - addBidResponse(adUnitCode: string, bid: object): unknown; highestBids(adUnitCode?: string): readonly object[]; processQueue(): unknown; + registerBidAdapter(adapter: unknown, bidderCode: string, spec?: object): unknown; renderAd(targetDocument: object, adId: string): unknown; requestBids(options: object): unknown; subscribe(eventType: string, listener: (event: unknown) => void): () => void; @@ -176,13 +176,11 @@ function frozenRecordValues( value: unknown, keys: readonly string[] ): Readonly> | undefined { - if ( - typeof value !== 'object' || - value === null || - Object.getPrototypeOf(value) !== Object.prototype - ) { + 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; @@ -224,7 +222,7 @@ function validString(value: unknown, maximumBytes: number, lowercase = false): v } function frozenArrayValues(value: unknown, maximumLength: number): readonly unknown[] | undefined { - if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + if (!Array.isArray(value)) return undefined; if (!Object.isFrozen(value)) return undefined; const descriptors = Object.getOwnPropertyDescriptors(value); const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); @@ -380,11 +378,11 @@ function validateStamp( const REQUIRED_API_METHODS = [ 'addAdUnits', - 'addBidResponse', 'getHighestCpmBids', 'offEvent', 'onEvent', 'processQueue', + 'registerBidAdapter', 'renderAd', 'requestBids', ] as const; @@ -559,8 +557,6 @@ export function createBrowserPrebidAdapter( Object.freeze({ addAdUnits: (adUnits: readonly unknown[]): unknown => callBound(binding, 'addAdUnits', [[...adUnits]], isOperationCurrent), - addBidResponse: (adUnitCode: string, bid: object): unknown => - callBound(binding, 'addBidResponse', [adUnitCode, bid], isOperationCurrent), highestBids: (adUnitCode?: string): readonly object[] => { const value = callBound( binding, @@ -575,6 +571,13 @@ export function createBrowserPrebidAdapter( return Object.freeze([...value]); }, 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 + ), renderAd: (targetDocument: object, adId: string): unknown => callBound(binding, 'renderAd', [targetDocument, adId], isOperationCurrent), requestBids: (options: object): unknown => diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index c6ce9f924..0b6108924 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -41,7 +41,6 @@ function createReadyPrebid( const listeners = new Map void>>(); const pbjs = { addAdUnits: vi.fn(), - addBidResponse: vi.fn(), getHighestCpmBids: vi.fn<() => object[]>(() => []), offEvent: vi.fn((type: string, listener: (event: unknown) => void) => { listeners.get(type)?.delete(listener); @@ -52,6 +51,7 @@ function createReadyPrebid( listeners.set(type, registered); }), processQueue: vi.fn(), + registerBidAdapter: vi.fn(), que: { push: vi.fn((command: Command): number => { if (options.deferCommands) commands.push(command); @@ -83,7 +83,7 @@ describe('browser Prebid adapter readiness', () => { expect('que' in prebid).toBe(false); expect('__trustedServerArtifactV1' in prebid).toBe(false); prebid.addAdUnits([{ code: 'slot-a' }]); - prebid.addBidResponse('slot-a', { adId: 'bid-a' }); + prebid.registerBidAdapter(undefined, 'trustedServer', { code: 'trustedServer' }); prebid.requestBids({ adUnitCodes: ['slot-a'] }); prebid.renderAd({}, 'bid-a'); return prebid.highestBids('slot-a'); @@ -92,7 +92,9 @@ describe('browser Prebid adapter readiness', () => { expect(operation.status).toBe('present'); await expect(operation.result).resolves.toEqual([]); expect(ready.pbjs.addAdUnits).toHaveBeenCalledTimes(1); - expect(ready.pbjs.addBidResponse).toHaveBeenCalledWith('slot-a', { adId: 'bid-a' }); + expect(ready.pbjs.registerBidAdapter).toHaveBeenCalledWith(undefined, 'trustedServer', { + code: 'trustedServer', + }); expect(ready.pbjs.requestBids).toHaveBeenCalledTimes(1); expect(ready.pbjs.renderAd).toHaveBeenCalledWith({}, 'bid-a'); }); @@ -601,11 +603,11 @@ describe('browser Prebid adapter readiness', () => { it('requires every real API method and contains hostile target and member getters', async () => { for (const method of [ 'addAdUnits', - 'addBidResponse', 'getHighestCpmBids', 'offEvent', 'onEvent', 'processQueue', + 'registerBidAdapter', 'renderAd', 'requestBids', ] as const) { 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 520723351..b6afbb0b2 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 @@ -13,6 +13,7 @@ import { main, parseArgs, readAdapterBidderCodes, + readAdapterMetadata, renderIncludedUserIdModulesExport, } from '../build-prebid-external.mjs'; @@ -38,6 +39,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', () => { @@ -116,4 +121,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/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index aaf2e5091..56c52349d 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, '..'); @@ -287,6 +288,9 @@ describe('external bundle + served shim evaluated together', () => { expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0])).toBe(true); expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0].configNames)).toBe(true); expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0].eidSources)).toBe(true); + const adapter = createBrowserPrebidAdapter(pageWindow); + expect(adapter.bindingStatus()).toBe('present'); + adapter.dispose(); // Count trustedServer registrations across repeated shim evaluations. const originalRegisterBidAdapter = pageWindow.pbjs.registerBidAdapter.bind(pageWindow.pbjs); From fa5eccc9d3aa8463e4b5ff9c9dbce77261fd8ab9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:30:16 -0700 Subject: [PATCH 091/194] Retire artifacts with GPT slot ownership --- .../lib/src/composition/browser.ts | 10 ++- .../lib/src/kernel/sessions.ts | 9 ++ .../lib/src/services/slots.ts | 41 ++++++++- .../lib/test/composition/browser.test.ts | 40 +++++++++ .../lib/test/kernel/sessions.test.ts | 19 ++++ .../lib/test/services/slots.test.ts | 86 +++++++++++++++---- 6 files changed, 184 insertions(+), 21 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 25d7c239f..a4c16a404 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -474,7 +474,14 @@ export function createTestBrowserRuntimeComposition( 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 } : {}), }); @@ -482,7 +489,6 @@ export function createTestBrowserRuntimeComposition( const reservationService = createReservationService({ prepareRenderSource: (candidate) => parseBidRenderSourceV1(candidate, cachePolicy), }); - const artifacts = createCommittedArtifactStore(); const rendererNonces = createRendererNonceRegistry(); const publisherOrigin = window.location.origin; const fetchCache = globalThis.fetch; @@ -654,6 +660,8 @@ export function createTestBrowserRuntimeComposition( createIdentityIssuer: compositionOptions.createIdentityIssuerForTest ?? createBrowserNavigationIdentityIssuer, interfaces: Object.freeze({ adapters: composition.adapters, gpt: gptRuntime, ...services }), + onNavigationDispose: (navigationGeneration) => + artifacts.disposeNavigation(navigationGeneration), }); context.onDispose(() => { batchCoordinator.dispose(); diff --git a/crates/trusted-server-js/lib/src/kernel/sessions.ts b/crates/trusted-server-js/lib/src/kernel/sessions.ts index a301cf9d1..4ae8d6fc2 100644 --- a/crates/trusted-server-js/lib/src/kernel/sessions.ts +++ b/crates/trusted-server-js/lib/src/kernel/sessions.ts @@ -29,6 +29,7 @@ export type RuntimeInterfaces = Readonly>; export interface RuntimeSessionOptions { readonly createIdentityIssuer: NavigationIdentityIssuerFactory; readonly interfaces?: RuntimeInterfaces; + readonly onNavigationDispose?: (navigationGeneration: object) => void; readonly onDisposalError?: DisposalErrorHandler; } @@ -597,6 +598,7 @@ class RuntimeSessionOwner implements RuntimeSession { public readonly interfaces: RuntimeInterfaces; private readonly scope: OwnerScope; private readonly createIdentityIssuer: NavigationIdentityIssuerFactory; + private readonly onNavigationDispose: ((navigationGeneration: object) => void) | undefined; private readonly onDisposalError: DisposalErrorHandler | undefined; private navigation: NavigationSessionOwner | undefined; private started = false; @@ -606,6 +608,8 @@ class RuntimeSessionOwner implements RuntimeSession { public constructor(options: RuntimeSessionOptions) { this.createIdentityIssuer = options.createIdentityIssuer; + this.onNavigationDispose = + typeof options.onNavigationDispose === 'function' ? options.onNavigationDispose : undefined; this.onDisposalError = options.onDisposalError; this.scope = new OwnerScope(options.onDisposalError); this.interfaces = options.interfaces ?? EMPTY_INTERFACES; @@ -730,6 +734,11 @@ class RuntimeSessionOwner implements RuntimeSession { }, this.onDisposalError ); + const onNavigationDispose = this.onNavigationDispose; + if (onNavigationDispose) { + const navigationGeneration = navigation.generation; + navigation.onDispose('navigation-lifecycle', () => onNavigationDispose(navigationGeneration)); + } navigationReference.current = navigation; return navigation; } diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 743aa6eee..f4e2ade95 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -160,6 +160,10 @@ export interface SlotService { } export interface SlotServiceOptions { + readonly disposeCommittedArtifact?: ( + navigationGeneration: object, + registeredSlotId: string + ) => void; readonly googletag: GoogletagAdapter; readonly now?: () => number; readonly reconciliation?: SlotReconciliationBoundary; @@ -203,6 +207,7 @@ interface PhysicalCycle { interface PhysicalSlot { activeCycle: PhysicalCycle | undefined; + artifactRetirementAttempted: boolean; definition: GoogletagReplacementDefinition | undefined; domElement: object | undefined; lastResponseIdentifier: string | undefined; @@ -629,6 +634,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const placementQuarantine = new Map(); const quarantinedKeysByPhysical = new WeakMap(); const now = options.now ?? (() => performance.now()); + const disposeCommittedArtifact = + typeof options.disposeCommittedArtifact === 'function' + ? options.disposeCommittedArtifact + : undefined; let reconciliationBoundary: SlotReconciliationBoundary | undefined; let reconciliationObserve: SlotReconciliationBoundary['observe'] | undefined; let reconciliationIsConnected: SlotReconciliationBoundary['isConnected'] | undefined; @@ -847,6 +856,16 @@ export function createSlotService(options: SlotServiceOptions): SlotService { invokeIntent(record, queued); }; + const retireCommittedArtifact = (record: InternalSlotRecord, physical: PhysicalSlot): void => { + if (physical.artifactRetirementAttempted) return; + physical.artifactRetirementAttempted = true; + try { + disposeCommittedArtifact?.(record.state.owner.generation, record.view.registeredSlotId); + } catch { + // Physical retirement remains authoritative when artifact cleanup throws. + } + }; + const prepareReplacementCommit = ( record: InternalSlotRecord, oldPhysical: PhysicalSlot, @@ -866,6 +885,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (existing) throw new GoogletagReplacementCandidateCollisionError(replacement); const physical: PhysicalSlot = { activeCycle: undefined, + artifactRetirementAttempted: false, definition, domElement, destroyAttempted: false, @@ -907,6 +927,8 @@ export function createSlotService(options: SlotServiceOptions): SlotService { addSetValue(physicalSlots, physical); if (!setHasValue(physicalSlots, physical)) return false; if (record.state.disposed || !record.state.owner.isCurrent()) return false; + retireCommittedArtifact(record, oldPhysical); + if (record.state.disposed || !record.state.owner.isCurrent()) return false; record.physical = physical; oldPhysical.record = undefined; deleteSetValue(physicalSlots, oldPhysical); @@ -924,6 +946,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (!orphanedSlot || orphanedSlot === source.slot) return; const orphan: PhysicalSlot = { activeCycle: undefined, + artifactRetirementAttempted: true, definition: source.definition, domElement: undefined, destroyAttempted: true, @@ -979,6 +1002,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { ) ); } catch { + retireCommittedArtifact(record, physical); physical.state = 'quarantined'; failQueued(record, 'gpt_request_failed'); return; @@ -994,6 +1018,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }; void operation.result.then( (result) => { + retireCommittedArtifact(record, physical); if (result.status !== 'replaced') { detachDestroyedOld(); failQueued(record, 'gpt_request_failed'); @@ -1007,6 +1032,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { advanceQueued(record); }, (error: unknown) => { + retireCommittedArtifact(record, physical); physical.state = 'quarantined'; const replacementError = error instanceof GoogletagReplacementError ? error : undefined; const reusedOldIdentity = replacementError?.orphanedSlot === physical.slot; @@ -1069,12 +1095,17 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return; } const physical = intent.record.physical; - if (physical?.activeCycle?.intent === intent) { - physical.activeCycle.intent = undefined; + const physicalCycle = physical?.activeCycle; + const ownsPhysicalCycle = physicalCycle?.intent === intent; + if (ownsPhysicalCycle && physical && physicalCycle) { + physicalCycle.intent = undefined; physical.state = 'quarantined'; physical.quarantineReason = 'completion'; } settle(intent, failed('gpt_completion_timeout')); + if (ownsPhysicalCycle && physical?.ownership === 'trusted_server') { + recoverRequestTimeout(intent.record, physical); + } }; const armRequestDeadline = (intent: RequestIntent): void => { @@ -1341,6 +1372,8 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }; const retirePhysicalForNavigation = (physical: PhysicalSlot): void => { + const record = physical.record; + if (record) retireCommittedArtifact(record, physical); physical.record = undefined; if (physical.ownership === 'publisher') { if (physical.activeCycle) { @@ -1440,6 +1473,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (record.physical !== physical || physical.ownership !== 'trusted_server') return; settleReconciliationWork(record, physical, reason); + retireCommittedArtifact(record, physical); record.physical = undefined; physical.record = undefined; physical.state = 'retired'; @@ -1490,6 +1524,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { ) { return false; } + retireCommittedArtifact(record, window.orphan); window.terminal = true; clearReconciliationTimers(window); window.operation?.dispose(); @@ -2038,6 +2073,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } const physical: PhysicalSlot = { activeCycle: undefined, + artifactRetirementAttempted: false, definition, domElement, destroyAttempted: false, @@ -2578,6 +2614,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (cycleIntent && !cycleIntent.terminal) settle(cycleIntent, failed('gpt_request_failed')); if (record?.activeIntent) settle(record.activeIntent, failed('gpt_request_failed')); if (record?.queuedIntent) settle(record.queuedIntent, failed('gpt_request_failed')); + if (record) retireCommittedArtifact(record, physical); if (record?.physical === physical) record.physical = undefined; physical.record = undefined; physical.activeCycle = undefined; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 0a7af0c7b..b10334550 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -729,6 +729,44 @@ describe('browser composition', () => { 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( ( @@ -740,6 +778,8 @@ describe('browser composition', () => { 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); diff --git a/crates/trusted-server-js/lib/test/kernel/sessions.test.ts b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts index 4fc6e31ca..5f305a647 100644 --- a/crates/trusted-server-js/lib/test/kernel/sessions.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts @@ -29,6 +29,25 @@ function frozenProjection(id: string): Readonly { } 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() }); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index b4a583829..27ca7772c 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -627,8 +627,12 @@ describe('navigation-owned DOM reconciliation', () => { 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, @@ -650,6 +654,7 @@ describe('navigation-owned DOM reconciliation', () => { [[300, 250]], 'slot-div' ); + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith(navigation.generation, 'slot'); const request = service.request({ intentId: 'after-rebind', @@ -743,6 +748,33 @@ describe('navigation-owned DOM reconciliation', () => { expect(gpt.defineSlot).not.toHaveBeenCalled(); }); + 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); @@ -2421,12 +2453,16 @@ describe('physical GPT cycles', () => { }); }); - it('keeps a completion-timeout cycle quarantined until its exact late completion drains', async () => { + it('recovers a completion timeout through the exact destroy/redefine transaction', async () => { vi.useFakeTimers(); const harness = createGptHarness(); - const service = createSlotService({ googletag: harness.adapter }); + const disposeCommittedArtifact = vi.fn(); + const service = createSlotService({ + disposeCommittedArtifact, + googletag: harness.adapter, + }); const navigation = createNavigation(); - const slot = bindTrustedSlot(service, navigation); + const oldSlot = bindTrustedSlot(service, navigation); const first = service.request({ intentId: 'completion-timeout', navigationGeneration: navigation.generation, @@ -2435,24 +2471,21 @@ describe('physical GPT cycles', () => { registeredSlotId: 'slot', }); await Promise.resolve(); - service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRequested', { slot: oldSlot }); await vi.advanceTimersByTimeAsync(10_000); await expect(first.result).resolves.toMatchObject({ reason: 'gpt_completion_timeout' }); - const blocked = service.request({ - intentId: 'blocked', - navigationGeneration: navigation.generation, - operation: 'refresh', - requestClass: 'primary', - registeredSlotId: 'slot', - }); - await expect(blocked.result).resolves.toMatchObject({ reason: 'slot_quarantined' }); + 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('slotRequested', { slot }); - expect(service.snapshotForTest().cycles).toBe(1); service.handleGptEvent('slotRenderEnded', { isEmpty: false, responseIdentifier: 'late-completion', - slot, + slot: oldSlot, }); const recovered = service.request({ intentId: 'recovered', @@ -2463,7 +2496,20 @@ describe('physical GPT cycles', () => { }); await Promise.resolve(); expect(recovered.status).toBe('active'); - recovered.dispose(); + 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 () => { @@ -3294,6 +3340,10 @@ describe('Task 11 adversarial ownership review', () => { 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', @@ -3304,8 +3354,8 @@ describe('Task 11 adversarial ownership review', () => { }); await Promise.resolve(); expect(harness.refresh).toHaveBeenCalledTimes(2); - service.handleGptEvent('slotRequested', { slot }); - service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + service.handleGptEvent('slotRequested', { slot: replacement }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot: replacement }); await expect(next.result).resolves.toMatchObject({ status: 'rendered' }); }); From f7e2dc0b90d9d59a2220d6d2e84435f870a65081 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:35:11 -0700 Subject: [PATCH 092/194] Prepare the transactional Prebid module --- .../lib/src/composition/browser.ts | 10 +- .../lib/src/integrations/prebid/module.ts | 118 +++++++++++++ .../lib/test/composition/browser.test.ts | 34 +++- .../test/integrations/prebid/module.test.ts | 157 ++++++++++++++++++ 4 files changed, 309 insertions(+), 10 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/prebid/module.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index a4c16a404..1df7a28a6 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -159,6 +159,7 @@ export interface TestBrowserRuntimeCompositionOptions extends BrowserComposition readonly createIdentityIssuerForTest?: NavigationIdentityIssuerFactory; readonly admittedProgrammaticSlotsForTest?: readonly string[]; readonly gptStartupForTest?: (config: unknown) => void; + readonly prebidStartupForTest?: (config: unknown) => void; } interface AcceptedBrowserBoot { @@ -254,6 +255,8 @@ export function createTestBrowserRuntimeComposition( const providedBindings = runtimeOptions.getBindings; const startGpt = compositionOptions.gptStartupForTest ?? (() => undefined); const gptRuntime = Object.freeze({ start: startGpt }); + const startPrebid = compositionOptions.prebidStartupForTest ?? (() => undefined); + const prebidRuntime = Object.freeze({ start: startPrebid }); let runtimeSession: RuntimeSession | undefined; const getBindings: NonNullable = (id) => { const provided = providedBindings?.(id); @@ -659,7 +662,12 @@ export function createTestBrowserRuntimeComposition( const session = createRuntimeSession({ createIdentityIssuer: compositionOptions.createIdentityIssuerForTest ?? createBrowserNavigationIdentityIssuer, - interfaces: Object.freeze({ adapters: composition.adapters, gpt: gptRuntime, ...services }), + interfaces: Object.freeze({ + adapters: composition.adapters, + gpt: gptRuntime, + prebid: prebidRuntime, + ...services, + }), onNavigationDispose: (navigationGeneration) => artifacts.disposeNavigation(navigationGeneration), }); diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts new file mode 100644 index 000000000..0d1ab8c1f --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -0,0 +1,118 @@ +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from '../../kernel/integration_registry'; + +export const PREBID_INTEGRATION_ID = 'prebid' as const; + +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; + +interface PrebidIntegrationRuntime { + readonly start: (config: unknown) => void; +} + +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 (let index = 0; index < names.length; index += 1) { + const name = names[index]; + if (name === undefined) return false; + 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 readPrebidRuntime( + interfaces: Readonly> +): PrebidIntegrationRuntime | undefined { + try { + const descriptor = objectGetOwnPropertyDescriptorIntrinsic(interfaces, PREBID_INTEGRATION_ID); + if (!descriptor || !('value' in descriptor)) return undefined; + const candidate = descriptor.value; + if ( + typeof candidate !== 'object' || + candidate === null || + arrayIsArrayIntrinsic(candidate) || + !objectIsFrozenIntrinsic(candidate) || + Reflect.ownKeys(candidate).length !== 1 + ) { + return undefined; + } + const start = objectGetOwnPropertyDescriptorIntrinsic(candidate, 'start'); + if (!start || !('value' in start) || typeof start.value !== 'function') return undefined; + return candidate as PrebidIntegrationRuntime; + } catch { + return undefined; + } +} + +/** Build the release-bound Prebid module registered by the coordinated runtime. */ +export function createPrebidIntegrationRegistration(release: string): IntegrationRegistration { + return Object.freeze({ + id: PREBID_INTEGRATION_ID, + release, + prepare: ({ config, interfaces }: IntegrationPrepareContext) => { + if (!validFrozenConfig(config)) throw new TypeError('Prebid integration config is invalid'); + const runtime = readPrebidRuntime(interfaces); + if (!runtime) throw new TypeError('Prebid integration runtime is unavailable'); + + return Object.freeze({ + activate: ({ afterCommit }: IntegrationActivationContext) => { + afterCommit(() => runtime.start(config)); + }, + }); + }, + }); +} diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index b10334550..25497640b 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -25,6 +25,7 @@ import { log as localLog } from '../../src/core/log'; import type { BrowserAuctionBidV1 } from '../../src/core/types'; import { createGptIntegrationRegistration } from '../../src/integrations/gpt/module'; import { isGuardInstalled, resetGuardState } from '../../src/integrations/gpt/script_guard'; +import { createPrebidIntegrationRegistration } from '../../src/integrations/prebid/module'; import { publicLog } from '../../src/kernel/fallback'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; import { @@ -590,16 +591,21 @@ describe('browser composition', () => { expect(releases).toEqual(['slotRenderEnded', 'slotRequested']); }); - it('injects the GPT module boundary and retains only server-frozen configuration', async () => { + it('injects GPT and Prebid module boundaries with only server-frozen configuration', async () => { const releaseId = 'a'.repeat(64); const target = {}; - const config = Object.freeze({ scriptUrl: '/integrations/gpt/script' }); - const providedBindings = vi.fn(() => ({ - config, + 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(config); + 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( @@ -609,9 +615,12 @@ describe('browser composition', () => { manifest: { version: 1, releaseId, - integrations: [{ id: 'gpt', required: true }], + integrations: [ + { id: 'gpt', required: true }, + { id: 'prebid', required: true }, + ], }, - knownIntegrationIds: Object.freeze(['gpt']), + knownIntegrationIds: Object.freeze(['gpt', 'prebid']), boot: { auctionProjection: { version: 1, @@ -632,6 +641,7 @@ describe('browser composition', () => { }, coreActivations: { correctnessGptListeners: vi.fn() }, gptStartupForTest: startGpt, + prebidStartupForTest: startPrebid, } ); @@ -640,10 +650,16 @@ describe('browser composition', () => { expect( composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) ).toBe(true); + expect( + composition.runtime.registerIntegration(createPrebidIntegrationRegistration(releaseId)) + ).toBe(true); await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); - expect(providedBindings).toHaveBeenCalledExactlyOnceWith('gpt'); - expect(startGpt).toHaveBeenCalledExactlyOnceWith(config); + 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' 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..d15af2ac2 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createPrebidIntegrationRegistration } from '../../../src/integrations/prebid/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, + integrations: ids.map((id) => ({ id, required: true })), + }; +} + +function registration( + id: string, + prepare: IntegrationRegistration['prepare'] +): IntegrationRegistration { + return Object.freeze({ id, release: RELEASE_ID, prepare }); +} + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +describe('transactional Prebid integration module', () => { + it('prepares inertly and starts the external boundary 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); + }); + 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({ start }) }), + }), + }); + registry.register(createPrebidIntegrationRegistration(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', 'gate:activate', 'publish', 'start', 'drain']); + expect(start).toHaveBeenCalledExactlyOnceWith(config); + if (result.state === 'kernel') result.dispose(); + }); + + 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(createPrebidIntegrationRegistration(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({ start }) }), + }), + }); + registry.register(createPrebidIntegrationRegistration(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({ start }) }), + }), + }); + registry.register(createPrebidIntegrationRegistration(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' }]); + }); +}); From a1f0f00be2eb9e110f56e3dd9b1ffefbaa5be658 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:41:42 -0700 Subject: [PATCH 093/194] Publish Prebid bids transactionally --- .../lib/src/integrations/prebid/module.ts | 232 ++++++++++++++++++ .../test/integrations/prebid/module.test.ts | 216 +++++++++++++++- 2 files changed, 447 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 0d1ab8c1f..0b2bc4d59 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -1,8 +1,17 @@ +import { + isRendererReservationIdV1, + ownDataObject, + validBoundedString, + validDimension, +} from '../../core/contracts/auction_projection'; +import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../core/types'; import type { IntegrationActivationContext, IntegrationPrepareContext, IntegrationRegistration, } from '../../kernel/integration_registry'; +import type { NavigationSession } from '../../kernel/sessions'; +import type { ReservationService } from '../../services/reservations'; export const PREBID_INTEGRATION_ID = 'prebid' as const; @@ -116,3 +125,226 @@ export function createPrebidIntegrationRegistration(release: string): Integratio }, }); } + +/** Exact TS-owned bid passed to the version-pinned Prebid admission 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 PrebidBidPublicationFailureReason = + | 'descriptor_invalid' + | 'prebid_admission_failed' + | 'prebid_contract_violation' + | 'registry_full' + | 'reservation_collision' + | 'winner_not_renderable'; + +export type PrebidBidPublicationResult = + | Readonly<{ ok: true; bid: Readonly }> + | Readonly<{ ok: false; reason: PrebidBidPublicationFailureReason }>; + +type PrebidPublicationNavigation = Pick< + NavigationSession, + 'currentAuctionProjection' | 'generation' | 'isCurrent' | 'onDispose' +>; + +export interface PrebidBidPublicationInput { + readonly admitTrustedBid: (preparedBid: Readonly) => unknown; + readonly auctionId: string; + readonly adUnitCode: string; + readonly bid: BrowserAuctionBidV1; + readonly generatedBid: unknown; + readonly navigation: PrebidPublicationNavigation; + readonly reservations: Pick; +} + +function isCurrentProjectedWinner(input: PrebidBidPublicationInput): boolean { + try { + const projection = input.navigation.currentAuctionProjection as + Readonly | undefined; + if ( + !projection || + !objectIsFrozenIntrinsic(projection) || + !objectIsFrozenIntrinsic(projection.auction) || + !objectIsFrozenIntrinsic(projection.auction.results) || + !objectIsFrozenIntrinsic(projection.bids) || + !objectIsFrozenIntrinsic(input.bid) || + !objectIsFrozenIntrinsic(input.bid.targeting) || + !objectIsFrozenIntrinsic(input.bid.renderSource) || + !input.navigation.isCurrent() || + input.auctionId !== projection.auction.auctionId || + input.adUnitCode !== input.bid.slot || + !isRendererReservationIdV1(input.bid.rendererReservationId) + ) { + return false; + } + + let bidMatches = 0; + for (let index = 0; index < projection.bids.length; index += 1) { + if (projection.bids[index] === input.bid) bidMatches += 1; + } + if (bidMatches !== 1) return false; + + let winnerMatches = 0; + for (let index = 0; index < projection.auction.results.length; index += 1) { + const result = projection.auction.results[index]; + if ( + result?.outcome === 'winner' && + result.slot === input.bid.slot && + result.candidateId === input.bid.candidateId + ) { + winnerMatches += 1; + } + } + return winnerMatches === 1; + } catch { + return false; + } +} + +function prepareTrustedBid( + input: PrebidBidPublicationInput +): Readonly | undefined { + try { + const generated = ownDataObject(input.generatedBid); + const width = input.bid.renderSource.width; + const height = input.bid.renderSource.height; + if ( + !generated || + !validBoundedString(generated.requestId, 64) || + !validBoundedString(generated.adId, 128) || + !Object.is(generated.cpm, input.bid.cpm) || + generated.width !== width || + generated.height !== height || + !validDimension(width) || + !validDimension(height) + ) { + return undefined; + } + + const advertiserDomains = Object.freeze([] as string[]); + const meta = Object.freeze({ + advertiserDomains, + tsAuctionId: input.auctionId, + tsBidId: input.bid.upstreamBidId, + }); + const creativeId = + input.bid.renderSource.type === 'aps' && input.bid.renderSource.creativeId + ? input.bid.renderSource.creativeId + : input.bid.upstreamBidId; + const bid = Object.freeze({ + requestId: generated.requestId, + adId: input.bid.rendererReservationId, + cpm: input.bid.cpm, + width, + height, + ad: '' as const, + ttl: 300 as const, + creativeId, + netRevenue: true as const, + currency: 'USD' as const, + bidderCode: 'trustedServer' as const, + meta, + }); + return Object.freeze({ auctionId: input.auctionId, adUnitCode: input.adUnitCode, bid }); + } catch { + return undefined; + } +} + +function registrationFailure(reason: string): PrebidBidPublicationFailureReason { + if (reason === 'reservation_collision') return 'reservation_collision'; + if (reason === 'registry_full') return 'registry_full'; + if (reason === 'stale_owner' || reason === 'service_disposed') { + return 'winner_not_renderable'; + } + if ( + reason === 'invalid_reservation_id' || + reason === 'invalid_slot' || + reason === 'invalid_render_source' || + reason === 'invalid_winner_context' || + reason === 'prebid_cpm_mismatch' + ) { + return 'descriptor_invalid'; + } + return 'prebid_admission_failed'; +} + +/** Register before exposing one TS-owned bid through the version-pinned Prebid boundary. */ +export function publishPrebidBid(input: PrebidBidPublicationInput): PrebidBidPublicationResult { + if (!isCurrentProjectedWinner(input)) { + return Object.freeze({ ok: false, reason: 'winner_not_renderable' }); + } + const preparedBid = prepareTrustedBid(input); + if (!preparedBid) return Object.freeze({ ok: false, reason: 'descriptor_invalid' }); + + const registration = (() => { + try { + return input.reservations.registerPrebidLease({ + reservationId: input.bid.rendererReservationId, + slot: input.bid.slot, + navigation: input.navigation, + auctionId: input.auctionId, + adUnitCode: input.adUnitCode, + renderSource: input.bid.renderSource, + winnerContext: Object.freeze({ selectedCpm: input.bid.cpm }), + prebidBid: preparedBid.bid, + }); + } catch { + return Object.freeze({ ok: false as const, reason: 'service_disposed' as const }); + } + })(); + if (!registration.ok) { + return Object.freeze({ ok: false, reason: registrationFailure(registration.reason) }); + } + + let failure: 'prebid_admission_failed' | 'prebid_contract_violation' | undefined; + try { + const admission = input.admitTrustedBid(preparedBid); + if (admission === 'not_admitted') failure = 'prebid_admission_failed'; + else if (admission !== 'admitted') failure = 'prebid_contract_violation'; + } catch { + failure = 'prebid_admission_failed'; + } + if (!failure) return Object.freeze({ ok: true, bid: preparedBid }); + + const tombstoned = (() => { + try { + return input.reservations.tombstonePrebidLease( + { + reservationId: input.bid.rendererReservationId, + auctionId: input.auctionId, + adUnitCode: input.adUnitCode, + navigationGeneration: input.navigation.generation, + }, + failure + ); + } catch { + return false; + } + })(); + return Object.freeze({ + ok: false, + reason: tombstoned ? failure : 'prebid_contract_violation', + }); +} 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 index d15af2ac2..15fab46be 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it, vi } from 'vitest'; -import { createPrebidIntegrationRegistration } from '../../../src/integrations/prebid/module'; +import { + createPrebidIntegrationRegistration, + publishPrebidBid, + type PrebidBidPublicationInput, + type PreparedTrustedBidV1, +} from '../../../src/integrations/prebid/module'; +import { createTestNavigationIdentityIssuer } from '../../../src/kernel/identity'; import { createIntegrationRegistry, type IntegrationInstallCallbacks, type IntegrationRegistration, } from '../../../src/kernel/integration_registry'; +import { createRuntimeSession } from '../../../src/kernel/sessions'; +import { createReservationService } from '../../../src/services/reservations'; const RELEASE_ID = 'a'.repeat(64); @@ -155,3 +163,209 @@ describe('transactional Prebid integration module', () => { expect(runtimeFailures).toEqual([{ id: 'prebid', phase: 'after_commit' }]); }); }); + +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 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, + }, + }; + return { + admitTrustedBid, + bid, + generatedBid, + input, + navigation, + order, + reservationId, + reservations, + runtime, + }; + } + + 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']); + 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.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(); + }); +}); From 32c8b8a4585be192a40903f19115d9718f74e36f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:43:32 -0700 Subject: [PATCH 094/194] Observe publisher GPT calls transactionally --- .../lib/src/adapters/googletag.ts | 234 +++++++++++++++++- .../lib/test/adapters/googletag.test.ts | 137 ++++++++++ .../lib/test/composition/browser.test.ts | 2 + .../lib/test/services/slots.test.ts | 2 + 4 files changed, 374 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index 7fa0124b3..08d461eda 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -83,6 +83,48 @@ export interface GoogletagTargetingObserver { readonly beforePublisherMutation: (slot: object, key?: string) => 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' }> | Readonly<{ action: 'suppress' }>; + readonly refresh?: ( + call: Readonly + ) => + | Readonly<{ action: 'forward' }> + | Readonly<{ action: 'replace'; slots: readonly object[] }> + | 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[]; +} + /** The small GPT surface exposed to an accepted operation. */ export interface GoogletagFacade { bindingToken(): object; @@ -122,6 +164,7 @@ export interface GoogletagOperation { /** Narrow GPT boundary consumed by kernel sessions and services. */ export interface GoogletagAdapter { bindingStatus(): GoogletagBindingStatus; + observePublisherCalls(observer: GoogletagPublisherCallObserver): () => void; run( command: (googletag: Readonly) => T, options?: GoogletagOperationOptions @@ -750,6 +793,7 @@ export function createBrowserGoogletagAdapter( let pendingReservations = 0; let disposed = false; let firstDisplayObserved = false; + let trustedCallDepth = 0; const markFirstDisplay = (): void => { if (firstDisplayObserved) return; @@ -1481,7 +1525,13 @@ export function createBrowserGoogletagAdapter( return; } try { - const value = operation.command(facade); + trustedCallDepth += 1; + let value: unknown; + try { + value = operation.command(facade); + } finally { + trustedCallDepth -= 1; + } if (operation.settled) return; if (disposed) { fail(operation, 'operation_disposed'); @@ -1744,8 +1794,190 @@ export function createBrowserGoogletagAdapter( 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 current = currentBinding(); + 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 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 tracker = ensureInitialLoadTracking(current.value, serviceObject); + const stillCurrent = (): boolean => + !disposed && + readTarget(target) === currentBindingObject && + Reflect.apply(current.value.pubads, currentBindingObject, []) === serviceObject; + 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 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 (trustedCallDepth > 0 || !stillCurrent()) { + return Reflect.apply(callable, this, arguments_); + } + return mediate(callable, this, arguments_); + }; + const restore = replaceMethod(external, key, wrapper, stillCurrent); + if (!restore) throw new GoogletagAdapterError('external_artifact_incompatible'); + restorers[restorers.length] = restore; + }; + 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) { + try { + const decision = displayObserver( + Object.freeze({ + target: arguments_[0], + initialLoadDisabled: tracker?.disabled === true, + }) + ); + if (decision?.action === 'suppress') return undefined; + } catch { + // Observer failure must leave the publisher call native. + } + } + 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) { + try { + const decision = refreshObserver( + Object.freeze({ requestedSlots: requested, slots: effective }) + ); + if (decision?.action === 'suppress') return undefined; + if (decision?.action === 'replace') { + const replacement = objectSlots(decision.slots); + if (replacement) { + return Reflect.apply(original, receiver, [replacement, ...arguments_.slice(1)]); + } + } + } catch { + // Observer failure must leave the publisher call native. + } + } + } + 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. + } + for (let index = restorers.length - 1; index >= 0; index -= 1) restorers[index]?.(); + }; + registerAdapterEffect(release); + return release; + }; + return Object.freeze({ bindingStatus: (): GoogletagBindingStatus => currentBinding().status, + observePublisherCalls, run, notifyReady, dispose: (): void => { diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index ed496a76c..dce6d1c01 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -37,6 +37,8 @@ function createReadyGoogletag( return commands.length; }), }, + defineSlot: vi.fn(), + destroySlots: vi.fn(), display, getConfig: vi.fn((key: string) => key === 'disableInitialLoad' ? { disableInitialLoad: initialLoad.disabled } : {} @@ -1919,6 +1921,141 @@ describe('browser googletag adapter readiness', () => { expect(targeting.has('hb_adid')).toBe(false); }); + it('exposes one reversible publisher-call observer without changing ordinary calls', () => { + const ready = createReadyGoogletag(); + const nativeDisplay = ready.googletag.display; + const nativeRefresh = ready.pubads.refresh; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const boundary = adapter as unknown as { + observePublisherCalls?: (observer: object) => () => void; + }; + + expect(boundary.observePublisherCalls).toBeTypeOf('function'); + if (!boundary.observePublisherCalls) return; + + const release = boundary.observePublisherCalls(Object.freeze({})); + expect(ready.googletag.display).not.toBe(nativeDisplay); + expect(ready.pubads.refresh).not.toBe(nativeRefresh); + + release(); + expect(ready.googletag.display).toBe(nativeDisplay); + expect(ready.pubads.refresh).toBe(nativeRefresh); + }); + + it('mediates only explicit publisher decisions and preserves receiver, arguments, return, throw, and order', () => { + const ready = createReadyGoogletag({ initialLoadDisabled: true }); + const handoffSlot = Object.freeze({ id: 'handoff' }); + const ordinarySlot = Object.freeze({ id: 'ordinary' }); + const refreshOptions = Object.freeze({ changeCorrelator: true, publisher: 'kept' }); + const defineReceiver = Object.freeze({ receiver: 'define' }); + const refreshReceiver = Object.freeze({ receiver: 'refresh' }); + const order: string[] = []; + const nativeDefineSlot = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:define'); + return Object.freeze({ arguments_, receiver: this }); + }); + const nativeDisplay = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:display'); + return Object.freeze({ arguments_, receiver: this }); + }); + const nativeRefresh = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:refresh'); + return Object.freeze({ arguments_, receiver: this }); + }); + const nativeDestroy = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:destroy'); + return arguments_[0] === 'throw' + ? (() => { + throw new Error('publisher destroy failed'); + })() + : true; + }); + Object.assign(ready.googletag, { + defineSlot: nativeDefineSlot, + destroySlots: nativeDestroy, + display: nativeDisplay, + }); + ready.pubads.refresh = nativeRefresh; + ready.pubads.getSlots.mockReturnValue([handoffSlot, ordinarySlot]); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + let suppressDisplay = true; + const destroyed: Array = []; + const release = adapter.observePublisherCalls({ + defineSlot: (call) => { + order.push('observer:define'); + expect(call.initialLoadDisabled).toBe(true); + return call.elementId === 'handoff-id' + ? Object.freeze({ action: 'handoff' as const, slot: handoffSlot }) + : Object.freeze({ action: 'forward' as const }); + }, + destroySlots: (call) => { + order.push('observer:destroy'); + destroyed.push(call.slots); + }, + display: () => { + order.push('observer:display'); + if (!suppressDisplay) return Object.freeze({ action: 'forward' as const }); + suppressDisplay = false; + return Object.freeze({ action: 'suppress' as const }); + }, + refresh: (call) => { + order.push('observer:refresh'); + expect(call.requestedSlots).toBeUndefined(); + expect(call.slots).toEqual([handoffSlot, ordinarySlot]); + return Object.freeze({ action: 'replace' as const, slots: Object.freeze([ordinarySlot]) }); + }, + }); + + const defineSlot = ready.googletag.defineSlot as unknown as ( + ...arguments_: unknown[] + ) => unknown; + expect( + Reflect.apply(defineSlot, defineReceiver, ['/publisher', [300, 250], 'handoff-id']) + ).toBe(handoffSlot); + expect(nativeDefineSlot).not.toHaveBeenCalled(); + const forwarded = Reflect.apply(defineSlot, defineReceiver, [ + '/publisher', + [728, 90], + 'ordinary-id', + 'publisher-extra', + ]); + expect(forwarded).toEqual({ + arguments_: ['/publisher', [728, 90], 'ordinary-id', 'publisher-extra'], + receiver: defineReceiver, + }); + + const display = ready.googletag.display as (...arguments_: unknown[]) => unknown; + expect(Reflect.apply(display, defineReceiver, ['handoff-id'])).toBeUndefined(); + expect(Reflect.apply(display, defineReceiver, ['handoff-id', 'publisher-extra'])).toEqual({ + arguments_: ['handoff-id', 'publisher-extra'], + receiver: defineReceiver, + }); + + const refresh = ready.pubads.refresh as (...arguments_: unknown[]) => unknown; + expect(Reflect.apply(refresh, refreshReceiver, [undefined, refreshOptions])).toEqual({ + arguments_: [[ordinarySlot], refreshOptions], + receiver: refreshReceiver, + }); + + const destroySlots = ready.googletag.destroySlots as unknown as ( + slots?: readonly object[] + ) => unknown; + expect(destroySlots([handoffSlot])).toBe(true); + expect(destroyed).toEqual([[handoffSlot]]); + expect(order).toEqual([ + 'observer:define', + 'native:define', + 'observer:display', + 'native:display', + 'observer:refresh', + 'native:refresh', + 'native:destroy', + 'observer:destroy', + ]); + + release(); + }); + it('tracks native GPT initial-load configuration without duplicate wrappers', async () => { const ready = createReadyGoogletag({ initialLoadDisabled: true }); const nativeSetConfig = ready.googletag.setConfig; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 25497640b..3df0f52fa 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -89,6 +89,7 @@ function synchronousGptAdapter() { bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => Value) => { let result: Promise; try { @@ -537,6 +538,7 @@ describe('browser composition', () => { bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => T) => { const result = Promise.resolve(command(facade)); return Object.freeze({ status: 'present' as const, result, dispose: vi.fn() }); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 27ca7772c..1f260c66d 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -134,6 +134,7 @@ function createGptHarness( bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => T) => { let disposed = false; const dispose = vi.fn(() => { @@ -3687,6 +3688,7 @@ describe('Task 11 adversarial ownership review', () => { bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => T) => { let value: T; try { From 7afa7b9445e983168dbc42df3bc750476dedddae Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:45:56 -0700 Subject: [PATCH 095/194] Scope Prebid queries to event callbacks --- .../lib/src/adapters/prebid.ts | 58 ++++++++++++++----- .../lib/test/adapters/prebid.test.ts | 28 ++++++++- 2 files changed, 69 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index a0d204e27..6075b122b 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -64,6 +64,11 @@ export interface PrebidArtifactRequirements { }>[]; } +/** 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; @@ -72,7 +77,10 @@ export interface PrebidFacade { registerBidAdapter(adapter: unknown, bidderCode: string, spec?: object): unknown; renderAd(targetDocument: object, adId: string): unknown; requestBids(options: object): unknown; - subscribe(eventType: string, listener: (event: unknown) => void): () => void; + subscribe( + eventType: string, + listener: (event: unknown, prebid: Readonly) => void + ): () => void; } /** Options owned by one Prebid operation. */ @@ -548,6 +556,24 @@ export function createBrowserPrebidAdapter( return result; }; + 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 createFacade = ( binding: PresentPrebid, registerOperationEffect: (disposeEffect: () => void) => () => void, @@ -557,19 +583,8 @@ export function createBrowserPrebidAdapter( Object.freeze({ addAdUnits: (adUnits: readonly unknown[]): unknown => callBound(binding, 'addAdUnits', [[...adUnits]], isOperationCurrent), - highestBids: (adUnitCode?: string): readonly object[] => { - const value = callBound( - binding, - 'getHighestCpmBids', - adUnitCode === undefined ? [] : [adUnitCode], - isOperationCurrent - ); - if (!Array.isArray(value) || value.some((bid) => typeof bid !== 'object' || bid === null)) { - throw new PrebidAdapterError('external_artifact_incompatible'); - } - if (!isOperationCurrent()) throw new PrebidAdapterError('external_artifact_incompatible'); - return Object.freeze([...value]); - }, + highestBids: (adUnitCode?: string): readonly object[] => + highestBids(binding, adUnitCode, isOperationCurrent), processQueue: (): unknown => callBound(binding, 'processQueue', [], isOperationCurrent), registerBidAdapter: (adapter: unknown, bidderCode: string, spec?: object): unknown => callBound( @@ -582,7 +597,10 @@ export function createBrowserPrebidAdapter( callBound(binding, 'renderAd', [targetDocument, adId], isOperationCurrent), requestBids: (options: object): unknown => callBound(binding, 'requestBids', [options], isOperationCurrent), - subscribe: (eventType: string, listener: (event: unknown) => void): (() => void) => { + 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') @@ -592,10 +610,18 @@ export function createBrowserPrebidAdapter( 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); + listener(event, eventFacade); } catch { // Publisher callbacks cannot escape the Prebid boundary. + } finally { + callbackActive = false; } }; let attempted = false; diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index 0b6108924..e9933ec82 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createBrowserPrebidAdapter } from '../../src/adapters/prebid'; +import { createBrowserPrebidAdapter, type PrebidEventFacade } from '../../src/adapters/prebid'; type Command = () => void; @@ -958,6 +958,32 @@ describe('browser Prebid adapter readiness', () => { 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 }); From fd09a6f8ad96e16d313f7bc5e0048fe3445f9f49 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:47:14 -0700 Subject: [PATCH 096/194] Tombstone unselected Prebid groups --- crates/trusted-server-js/lib/src/services/reservations.ts | 6 +++--- .../lib/test/services/reservations.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/reservations.ts b/crates/trusted-server-js/lib/src/services/reservations.ts index 49ba225f7..75bb8c52f 100644 --- a/crates/trusted-server-js/lib/src/services/reservations.ts +++ b/crates/trusted-server-js/lib/src/services/reservations.ts @@ -357,7 +357,7 @@ export interface ReservationService { readonly tombstone: (input: ReservationTombstoneInput, state: 'disposed' | 'stale') => boolean; readonly tombstonePrebidGroup: ( input: PrebidGroupOwnerInput, - state: 'aborted' | 'prebid_selection_timeout' + state: 'aborted' | 'prebid_selection_timeout' | 'unselected' ) => number; readonly tombstonePrebidLease: ( input: PrebidLeaseOwnerInput, @@ -439,8 +439,8 @@ function validPrebidLeaseTombstoneState( function validPrebidGroupTombstoneState( value: unknown -): value is 'aborted' | 'prebid_selection_timeout' { - return value === 'aborted' || value === 'prebid_selection_timeout'; +): value is 'aborted' | 'prebid_selection_timeout' | 'unselected' { + return value === 'aborted' || value === 'prebid_selection_timeout' || value === 'unselected'; } function winnerContext(value: unknown): WinnerContext | undefined { diff --git a/crates/trusted-server-js/lib/test/services/reservations.test.ts b/crates/trusted-server-js/lib/test/services/reservations.test.ts index b7446c5ad..fa34691dd 100644 --- a/crates/trusted-server-js/lib/test/services/reservations.test.ts +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -1726,7 +1726,7 @@ describe('Prebid admission leases and selection', () => { expect(service.recognize(reservationId(2))).toMatchObject({ state: 'unselected' }); }); - it.each(['aborted', 'prebid_selection_timeout'] as const)( + it.each(['aborted', 'prebid_selection_timeout', 'unselected'] as const)( 'tombstones %s leases only through their original admission expiry', (reason) => { let now = 25; From 665539ea786407264ca2df963f3c319a1bcb370e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:57:40 -0700 Subject: [PATCH 097/194] Admit Trusted Server bids through Prebid --- .../lib/src/adapters/prebid.ts | 440 ++++++++++++++++++ .../lib/test/adapters/prebid.test.ts | 231 ++++++++- 2 files changed, 670 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index 6075b122b..bd4bfb7e8 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -39,6 +39,58 @@ export class PrebidAdapterError extends Error { } } +/** 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: string; + 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; @@ -75,6 +127,9 @@ export interface PrebidFacade { highestBids(adUnitCode?: string): readonly object[]; processQueue(): unknown; registerBidAdapter(adapter: unknown, bidderCode: string, spec?: object): unknown; + registerTrustedServerBidder( + listener: (auction: Readonly) => void + ): unknown; renderAd(targetDocument: object, adId: string): unknown; requestBids(options: object): unknown; subscribe( @@ -98,6 +153,7 @@ export interface PrebidOperation { /** 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 @@ -148,6 +204,18 @@ interface PendingOperation { readonly provisionalEffects: ProvisionalEffect[]; } +interface ActiveTrustedServerAdmission { + readonly addBidResponse: (...arguments_: unknown[]) => unknown; + readonly binding: PresentPrebid; + readonly requests: readonly PrebidTrustedServerBidRequestV1[]; + readonly admittedIds: Set; + readonly admittedRequests: Set; + readonly attemptedRequests: Set; + readonly registration: object; + readonly violatedRequests: Set; + complete(): void; +} + const encoder = new TextEncoder(); function validUnicodeScalars(value: string): boolean { @@ -384,8 +452,79 @@ function validateStamp( } } +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', @@ -469,6 +608,8 @@ export function createBrowserPrebidAdapter( 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; @@ -556,6 +697,197 @@ export function createBrowserPrebidAdapter( return result; }; + const bidderRequestSnapshot = ( + candidate: unknown + ): + | Readonly<{ + auctionId: string; + bids: readonly PrebidTrustedServerBidRequestV1[]; + }> + | 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 requests: PrebidTrustedServerBidRequestV1[] = []; + 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 requestId = safeOwnDescriptor(rawBid, 'bidId'); + if ( + !adUnitCode || + !Object.prototype.hasOwnProperty.call(adUnitCode, 'value') || + !validString(adUnitCode.value, 256) || + !requestId || + !Object.prototype.hasOwnProperty.call(requestId, 'value') || + !validString(requestId.value, 128) + ) { + return undefined; + } + const identity = `${adUnitCode.value}\u0000${requestId.value}`; + if (identities.has(identity)) return undefined; + identities.add(identity); + requests.push(Object.freeze({ adUnitCode: adUnitCode.value, requestId: requestId.value })); + } + return Object.freeze({ auctionId: auctionId.value, bids: Object.freeze(requests) }); + } catch { + return undefined; + } + }; + + const responseCount = ( + binding: PresentPrebid, + adUnitCode: string, + adId: string, + requestId: string, + isCurrent: () => boolean + ): number => { + const response = callBound(binding, 'getBidResponsesForAdUnitCode', [adUnitCode], isCurrent); + if (typeof response !== 'object' || response === null || Array.isArray(response)) { + throw new PrebidAdapterError('external_artifact_incompatible'); + } + const bids = safeMember(response, 'bids'); + if (!Array.isArray(bids)) throw new PrebidAdapterError('external_artifact_incompatible'); + let matches = 0; + for (const bid of bids) { + if (typeof bid !== 'object' || bid === null) { + throw new PrebidAdapterError('external_artifact_incompatible'); + } + if ( + 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}`; + if ( + !context.requests.some( + (request) => + request.adUnitCode === prepared.adUnitCode && request.requestId === prepared.bid.requestId + ) + ) { + 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.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, '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, + meta: { + ...prepared.bid.meta, + advertiserDomains: [...prepared.bid.meta.advertiserDomains], + }, + }; + 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.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, @@ -574,6 +906,109 @@ export function createBrowserPrebidAdapter( return Object.freeze([...value]); }; + const registerTrustedServerBidder = ( + binding: PresentPrebid, + listener: (auction: Readonly) => void, + registerOperationEffect: (disposeEffect: () => void) => () => void, + isOperationCurrent: () => boolean + ): unknown => { + 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.bids, + 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 { + return callBound( + binding, + 'registerBidAdapter', + [bidderFactory, 'trustedServer'], + isOperationCurrent + ); + } catch (error) { + release(); + throw error; + } + }; + const createFacade = ( binding: PresentPrebid, registerOperationEffect: (disposeEffect: () => void) => () => void, @@ -593,6 +1028,10 @@ export function createBrowserPrebidAdapter( spec === undefined ? [adapter, bidderCode] : [adapter, bidderCode, spec], isOperationCurrent ), + registerTrustedServerBidder: ( + listener: (auction: Readonly) => void + ): unknown => + registerTrustedServerBidder(binding, listener, registerOperationEffect, isOperationCurrent), renderAd: (targetDocument: object, adId: string): unknown => callBound(binding, 'renderAd', [targetDocument, adId], isOperationCurrent), requestBids: (options: object): unknown => @@ -1150,6 +1589,7 @@ export function createBrowserPrebidAdapter( }; return Object.freeze({ + admitTrustedBid, bindingStatus: (): PrebidBindingStatus => currentBinding().status, run, notifyReady, diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index e9933ec82..00cfc2982 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -41,6 +41,7 @@ function createReadyPrebid( const listeners = new Map void>>(); const pbjs = { addAdUnits: vi.fn(), + getBidResponsesForAdUnitCode: vi.fn<() => { bids: object[] }>(() => ({ bids: [] })), getHighestCpmBids: vi.fn<() => object[]>(() => []), offEvent: vi.fn((type: string, listener: (event: unknown) => void) => { listeners.get(type)?.delete(listener); @@ -77,7 +78,8 @@ describe('browser Prebid adapter readiness', () => { it('binds an exact valid artifact and exposes a frozen narrow facade', async () => { const ready = createReadyPrebid(); - const adapter = createBrowserPrebidAdapter({ pbjs: ready.pbjs }); + 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); @@ -1521,3 +1523,230 @@ describe('browser Prebid adapter readiness', () => { 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) => ({ + bids: 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): unknown; + }; + 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', bidId: 'request-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.toBeUndefined(); + + 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).toEqual(prepared.bid); + expect(admitted).not.toBe(prepared.bid); + 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('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 request and ad-unit identity', async () => { + const fixture = admissionFixture(); + await fixture.operation.result; + const prepared = preparedBid(); + fixture.stored.push({ + ...prepared.bid, + requestId: 'other-request', + adUnitCode: prepared.adUnitCode, + }); + fixture.admit.mockImplementation((adUnitCode, bid) => { + fixture.emitBidResponse({ + ...bid, + requestId: 'other-request', + 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('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' }) + ); + }); +}); From a7139e27c7ce34fc0e82449569cf6fbeb8586cfb Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:59:25 -0700 Subject: [PATCH 098/194] Coordinate Prebid winner selection --- .../lib/src/adapters/prebid.ts | 2 +- .../lib/src/integrations/prebid/module.ts | 430 ++++++++++++++++-- .../test/integrations/prebid/module.test.ts | 292 +++++++++++- 3 files changed, 688 insertions(+), 36 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index bd4bfb7e8..bee06c111 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -66,7 +66,7 @@ export interface PreparedTrustedBidV1 { readonly creativeId: string; readonly netRevenue: true; readonly currency: 'USD'; - readonly bidderCode: string; + readonly bidderCode: 'trustedServer'; readonly meta: Readonly<{ readonly advertiserDomains: readonly string[]; readonly tsAuctionId: string; diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 0b2bc4d59..0d89904f1 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -5,15 +5,30 @@ import { validDimension, } from '../../core/contracts/auction_projection'; import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../core/types'; +import { + PrebidAdmissionContractError, + type PrebidEventFacade, + type PreparedTrustedBidV1, +} from '../../adapters/prebid'; import type { IntegrationActivationContext, IntegrationPrepareContext, IntegrationRegistration, } from '../../kernel/integration_registry'; -import type { NavigationSession } from '../../kernel/sessions'; +import type { + AuctionBatchScope, + NavigationSession, + RenderAttemptScope, +} from '../../kernel/sessions'; +import type { + RenderAttempt, + RenderAttemptCreationResult, + RenderScheduler, +} from '../../services/render'; import type { ReservationService } from '../../services/reservations'; export const PREBID_INTEGRATION_ID = 'prebid' as const; +export type { PreparedTrustedBidV1 } from '../../adapters/prebid'; const MAX_CONFIG_DEPTH = 16; const MAX_CONFIG_NODES = 512; @@ -126,31 +141,6 @@ export function createPrebidIntegrationRegistration(release: string): Integratio }); } -/** Exact TS-owned bid passed to the version-pinned Prebid admission 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 PrebidBidPublicationFailureReason = | 'descriptor_invalid' | 'prebid_admission_failed' @@ -163,10 +153,7 @@ export type PrebidBidPublicationResult = | Readonly<{ ok: true; bid: Readonly }> | Readonly<{ ok: false; reason: PrebidBidPublicationFailureReason }>; -type PrebidPublicationNavigation = Pick< - NavigationSession, - 'currentAuctionProjection' | 'generation' | 'isCurrent' | 'onDispose' ->; +type PrebidPublicationNavigation = NavigationSession; export interface PrebidBidPublicationInput { readonly admitTrustedBid: (preparedBid: Readonly) => unknown; @@ -176,6 +163,10 @@ export interface PrebidBidPublicationInput { readonly generatedBid: unknown; readonly navigation: PrebidPublicationNavigation; readonly reservations: Pick; + readonly trackAdmittedBid: ( + preparedBid: Readonly, + navigation: PrebidPublicationNavigation + ) => boolean; } function isCurrentProjectedWinner(input: PrebidBidPublicationInput): boolean { @@ -323,10 +314,22 @@ export function publishPrebidBid(input: PrebidBidPublicationInput): PrebidBidPub const admission = input.admitTrustedBid(preparedBid); if (admission === 'not_admitted') failure = 'prebid_admission_failed'; else if (admission !== 'admitted') failure = 'prebid_contract_violation'; - } catch { - failure = 'prebid_admission_failed'; + } catch (error) { + failure = + error instanceof PrebidAdmissionContractError + ? 'prebid_contract_violation' + : 'prebid_admission_failed'; + } + if (!failure) { + try { + if (input.trackAdmittedBid(preparedBid, input.navigation)) { + return Object.freeze({ ok: true, bid: preparedBid }); + } + } catch { + // A published bid without selection ownership must stay suppress-only. + } + failure = 'prebid_contract_violation'; } - if (!failure) return Object.freeze({ ok: true, bid: preparedBid }); const tombstoned = (() => { try { @@ -348,3 +351,364 @@ export function publishPrebidBid(input: PrebidBidPublicationInput): PrebidBidPub reason: tombstoned ? failure : 'prebid_contract_violation', }); } + +export interface PrebidSelectionCoordinatorOptions { + readonly activateAttempt: ( + input: Readonly<{ + attempt: RenderAttempt; + owner: RenderAttemptScope; + preparedBid: Readonly; + }> + ) => boolean; + readonly createAttempt: (owner: RenderAttemptScope) => RenderAttemptCreationResult; + readonly reservations: Pick< + ReservationService, + 'promotePrebidSelection' | 'tombstone' | 'tombstonePrebidGroup' + >; + readonly scheduler?: RenderScheduler; +} + +export interface PrebidSelectionCoordinator { + readonly track: ( + preparedBid: Readonly, + navigation: NavigationSession + ) => boolean; + readonly auctionEnded: (event: unknown, prebid: Readonly) => void; + readonly abort: (navigation: NavigationSession, auctionId: string) => void; + readonly dispose: () => void; +} + +interface TrackedPrebidGroup { + readonly adUnitCode: string; + readonly auction: TrackedPrebidAuction; + readonly bids: Map>; + active: boolean; + timer: unknown; +} + +interface TrackedPrebidAuction { + readonly auctionId: string; + readonly batch: AuctionBatchScope; + readonly groups: Map; + readonly navigation: NavigationSession; + active: boolean; + promotedAttempts: number; +} + +const PREBID_SELECTION_TIMEOUT_MS = 10_000; + +function defaultSelectionScheduler(): RenderScheduler { + return Object.freeze({ + clear: (handle: unknown): void => { + globalThis.clearTimeout(handle as ReturnType); + }, + set: (callback: () => void, milliseconds: number): unknown => + globalThis.setTimeout(callback, milliseconds), + }); +} + +function exactSelectedBid( + candidate: unknown, + group: TrackedPrebidGroup +): Readonly | undefined { + const record = ownDataObject(candidate); + if ( + !record || + record.auctionId !== group.auction.auctionId || + record.adUnitCode !== group.adUnitCode || + typeof record.adId !== 'string' + ) { + return undefined; + } + const prepared = group.bids.get(record.adId); + if (!prepared) return undefined; + const meta = ownDataObject(record.meta); + return record.requestId === prepared.bid.requestId && + Object.is(record.cpm, prepared.bid.cpm) && + record.bidderCode === prepared.bid.bidderCode && + meta?.tsAuctionId === prepared.auctionId && + meta.tsBidId === prepared.bid.meta.tsBidId + ? prepared + : undefined; +} + +/** Own short Prebid-selection leases without exposing reservation state to the artifact. */ +export function createPrebidSelectionCoordinator( + options: PrebidSelectionCoordinatorOptions +): PrebidSelectionCoordinator { + const scheduler = options.scheduler ?? defaultSelectionScheduler(); + const auctions: TrackedPrebidAuction[] = []; + let disposed = false; + + const removeAuction = (auction: TrackedPrebidAuction): void => { + const index = auctions.indexOf(auction); + if (index >= 0) auctions.splice(index, 1); + auction.active = false; + }; + + const clearGroupTimer = (group: TrackedPrebidGroup): void => { + if (group.timer === undefined) return; + const timer = group.timer; + group.timer = undefined; + try { + scheduler.clear(timer); + } catch { + // Timer cleanup cannot weaken reservation suppression. + } + }; + + const finishGroup = ( + group: TrackedPrebidGroup, + state?: 'aborted' | 'prebid_selection_timeout' | 'unselected' + ): void => { + if (!group.active) return; + group.active = false; + clearGroupTimer(group); + if (state) { + try { + options.reservations.tombstonePrebidGroup( + { + auctionId: group.auction.auctionId, + adUnitCode: group.adUnitCode, + navigationGeneration: group.auction.navigation.generation, + }, + state + ); + } catch { + // The bounded reservation service remains the suppression authority. + } + } + group.auction.groups.delete(group.adUnitCode); + if (group.auction.groups.size !== 0) return; + if (group.auction.promotedAttempts === 0) { + try { + group.auction.batch.dispose(); + } catch { + // Navigation disposal remains the final owner of a hostile batch. + } + } + removeAuction(group.auction); + }; + + const findAuction = ( + navigation: NavigationSession, + auctionId: string + ): TrackedPrebidAuction | undefined => { + for (let index = 0; index < auctions.length; index += 1) { + const auction = auctions[index]; + if (auction?.active && auction.navigation === navigation && auction.auctionId === auctionId) { + return auction; + } + } + return undefined; + }; + + const track = ( + preparedBid: Readonly, + navigation: NavigationSession + ): boolean => { + try { + if ( + disposed || + !navigation.isCurrent() || + !Object.isFrozen(preparedBid) || + !Object.isFrozen(preparedBid.bid) || + !isRendererReservationIdV1(preparedBid.bid.adId) + ) { + return false; + } + let auction = findAuction(navigation, preparedBid.auctionId); + let createdAuction = false; + if (!auction) { + const batch = navigation.createAuctionBatch(`prebid:${preparedBid.auctionId}`); + if (!batch) return false; + auction = { + auctionId: preparedBid.auctionId, + batch, + groups: new Map(), + navigation, + active: true, + promotedAttempts: 0, + }; + createdAuction = true; + } + let group = auction.groups.get(preparedBid.adUnitCode); + if (group?.bids.has(preparedBid.bid.adId)) return false; + if (!group) { + group = { + adUnitCode: preparedBid.adUnitCode, + auction, + bids: new Map(), + active: true, + timer: undefined, + }; + group.bids.set(preparedBid.bid.adId, preparedBid); + auction.groups.set(preparedBid.adUnitCode, group); + if (createdAuction) auctions.push(auction); + let timer: unknown; + try { + timer = scheduler.set( + () => finishGroup(group as TrackedPrebidGroup, 'prebid_selection_timeout'), + PREBID_SELECTION_TIMEOUT_MS + ); + if (!group.active) { + try { + scheduler.clear(timer); + } catch { + // The synchronously-fired logical deadline remains terminal. + } + return false; + } + group.timer = timer; + navigation.onDispose('prebid-selection', () => + finishGroup(group as TrackedPrebidGroup, 'aborted') + ); + if (!group.active || !navigation.isCurrent()) { + finishGroup(group, 'aborted'); + return false; + } + } catch { + if (timer !== undefined && group.timer === undefined) { + try { + scheduler.clear(timer); + } catch { + // Failed publication retains no live logical deadline. + } + } + finishGroup(group); + return false; + } + return true; + } + group.bids.set(preparedBid.bid.adId, preparedBid); + return true; + } catch { + return false; + } + }; + + const auctionEnded = (event: unknown, prebid: Readonly): void => { + if (disposed) return; + const record = ownDataObject(event); + if (!record || !validBoundedString(record.auctionId, 128)) return; + const snapshot = auctions.slice(); + for (let auctionIndex = 0; auctionIndex < snapshot.length; auctionIndex += 1) { + const auction = snapshot[auctionIndex]; + if (!auction?.active || auction.auctionId !== record.auctionId) continue; + const groups = [...auction.groups.values()]; + for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) { + const group = groups[groupIndex]; + if (!group?.active) continue; + let highest: readonly object[]; + try { + highest = prebid.highestBids(group.adUnitCode); + } catch { + continue; + } + const selected: Readonly[] = []; + for (let bidIndex = 0; bidIndex < highest.length; bidIndex += 1) { + const match = exactSelectedBid(highest[bidIndex], group); + if (match) selected.push(match); + } + if (selected.length !== 1) { + finishGroup(group, 'unselected'); + continue; + } + const prepared = selected[0]; + if (!prepared) { + finishGroup(group, 'unselected'); + continue; + } + const owner = auction.batch.createRenderAttempt(group.adUnitCode); + if (!owner.ok) { + finishGroup(group, 'unselected'); + continue; + } + const created = options.createAttempt(owner.value); + if (!created.ok) { + owner.value.dispose(); + finishGroup(group, 'unselected'); + continue; + } + const promotion = options.reservations.promotePrebidSelection({ + reservationId: prepared.bid.adId, + auctionId: prepared.auctionId, + adUnitCode: prepared.adUnitCode, + navigationGeneration: auction.navigation.generation, + attempt: owner.value, + prebidBid: prepared.bid, + }); + if (!promotion.ok) { + created.value.fail('prebid_contract_violation'); + finishGroup(group, 'unselected'); + continue; + } + let activated: boolean; + try { + activated = + options.activateAttempt( + Object.freeze({ attempt: created.value, owner: owner.value, preparedBid: prepared }) + ) === true; + } catch { + activated = false; + } + if (!activated) { + try { + options.reservations.tombstone( + { + reservationId: prepared.bid.adId, + slot: prepared.adUnitCode, + navigationGeneration: auction.navigation.generation, + attemptId: owner.value.id, + }, + 'stale' + ); + } catch { + // A failed PUC activation remains terminal at the attempt boundary. + } + created.value.fail('prebid_contract_violation'); + finishGroup(group); + continue; + } + auction.promotedAttempts += 1; + finishGroup(group); + } + } + }; + + const abort = (navigation: NavigationSession, auctionId: string): void => { + const auction = findAuction(navigation, auctionId); + if (!auction) return; + const groups = [...auction.groups.values()]; + for (let index = 0; index < groups.length; index += 1) { + const group = groups[index]; + if (group) finishGroup(group, 'aborted'); + } + }; + + return Object.freeze({ + track, + auctionEnded, + abort, + dispose: (): void => { + if (disposed) return; + disposed = true; + const snapshot = auctions.slice(); + for (let index = 0; index < snapshot.length; index += 1) { + const auction = snapshot[index]; + if (!auction) continue; + const groups = [...auction.groups.values()]; + for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) { + const group = groups[groupIndex]; + if (group) finishGroup(group, 'aborted'); + } + try { + auction.batch.dispose(); + } catch { + // Runtime disposal remains terminal under hostile callbacks. + } + } + auctions.length = 0; + }, + }); +} 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 index 15fab46be..0cc53e947 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; +import { PrebidAdmissionContractError } from '../../../src/adapters/prebid'; import { + createPrebidSelectionCoordinator, createPrebidIntegrationRegistration, publishPrebidBid, type PrebidBidPublicationInput, @@ -12,7 +14,12 @@ import { type IntegrationInstallCallbacks, type IntegrationRegistration, } from '../../../src/kernel/integration_registry'; -import { createRuntimeSession } from '../../../src/kernel/sessions'; +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); @@ -235,6 +242,10 @@ describe('ordered Prebid bid publication', () => { }); return 'admitted' as const; }); + const trackAdmittedBid = vi.fn(() => { + order.push('track'); + return true; + }); const input: PrebidBidPublicationInput = { admitTrustedBid, auctionId: 'auction-one', @@ -249,6 +260,7 @@ describe('ordered Prebid bid publication', () => { }, tombstonePrebidLease: reservations.tombstonePrebidLease, }, + trackAdmittedBid, }; return { admitTrustedBid, @@ -260,6 +272,7 @@ describe('ordered Prebid bid publication', () => { reservationId, reservations, runtime, + trackAdmittedBid, }; } @@ -269,7 +282,7 @@ describe('ordered Prebid bid publication', () => { const result = publishPrebidBid(publication.input); expect(result.ok).toBe(true); - expect(publication.order).toEqual(['reservation', 'admit']); + 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'); @@ -303,6 +316,32 @@ describe('ordered Prebid bid publication', () => { 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'], [ @@ -369,3 +408,252 @@ describe('ordered Prebid bid publication', () => { malformed.runtime.dispose(); }); }); + +describe('Prebid selection coordination', () => { + function prepareSelection(activateResult = true, synchronousTimer = false) { + 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(() => activateResult); + const coordinator = createPrebidSelectionCoordinator({ + activateAttempt, + createAttempt: (owner) => { + 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); + return result; + }, + reservations: { + promotePrebidSelection: (input) => { + 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 (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(!synchronousTimer); + return prepared; + }; + return { + admitted, + activateAttempt, + attempts, + attemptOwners, + cleared, + coordinator, + navigation, + promotions, + reservations, + runtime, + setNow: (value: number) => { + now = value; + }, + timers, + }; + } + + 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(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('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('rolls back a scheduler that invokes the deadline before timer publication returns', () => { + const harness = prepareSelection(true, 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(); + }); +}); From 54ea2ef748d9832b50895c2e8a15d9898b135ec2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:02:27 -0700 Subject: [PATCH 099/194] Fail closed during Prebid winner selection --- .../lib/src/integrations/prebid/module.ts | 36 ++++++-- .../test/integrations/prebid/module.test.ts | 89 +++++++++++++++++-- 2 files changed, 110 insertions(+), 15 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 0d89904f1..b5672d18f 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -605,6 +605,10 @@ export function createPrebidSelectionCoordinator( } catch { continue; } + if (highest.length !== 1) { + finishGroup(group, 'unselected'); + continue; + } const selected: Readonly[] = []; for (let bidIndex = 0; bidIndex < highest.length; bidIndex += 1) { const match = exactSelectedBid(highest[bidIndex], group); @@ -624,20 +628,34 @@ export function createPrebidSelectionCoordinator( finishGroup(group, 'unselected'); continue; } - const created = options.createAttempt(owner.value); + let created: RenderAttemptCreationResult; + try { + created = options.createAttempt(owner.value); + } catch { + owner.value.dispose(); + finishGroup(group, 'unselected'); + continue; + } if (!created.ok) { owner.value.dispose(); finishGroup(group, 'unselected'); continue; } - const promotion = options.reservations.promotePrebidSelection({ - reservationId: prepared.bid.adId, - auctionId: prepared.auctionId, - adUnitCode: prepared.adUnitCode, - navigationGeneration: auction.navigation.generation, - attempt: owner.value, - prebidBid: prepared.bid, - }); + let promotion: ReturnType; + try { + promotion = options.reservations.promotePrebidSelection({ + reservationId: prepared.bid.adId, + auctionId: prepared.auctionId, + adUnitCode: prepared.adUnitCode, + navigationGeneration: auction.navigation.generation, + attempt: owner.value, + prebidBid: prepared.bid, + }); + } catch { + created.value.fail('prebid_contract_violation'); + finishGroup(group, 'unselected'); + continue; + } if (!promotion.ok) { created.value.fail('prebid_contract_violation'); finishGroup(group, 'unselected'); 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 index 0cc53e947..883aec893 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -410,7 +410,14 @@ describe('ordered Prebid bid publication', () => { }); describe('Prebid selection coordination', () => { - function prepareSelection(activateResult = true, synchronousTimer = false) { + function prepareSelection( + options: Readonly<{ + activateResult?: boolean; + synchronousTimer?: boolean; + throwCreateAttempt?: boolean; + throwPromotion?: boolean; + }> = {} + ) { let now = 0; const runtime = createRuntimeSession({ createIdentityIssuer: () => @@ -437,10 +444,11 @@ describe('Prebid selection coordination', () => { const attemptOwners: RenderAttemptScope[] = []; const timers = new Map void>(); const cleared: object[] = []; - const activateAttempt = vi.fn(() => activateResult); + 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, @@ -456,6 +464,7 @@ describe('Prebid selection coordination', () => { }, reservations: { promotePrebidSelection: (input) => { + if (options.throwPromotion) throw new Error('promotion failed'); const result = reservations.promotePrebidSelection(input); promotions.push(result); return result; @@ -472,7 +481,7 @@ describe('Prebid selection coordination', () => { expect(milliseconds).toBe(10_000); const handle = Object.freeze({}); timers.set(handle, callback); - if (synchronousTimer) callback(); + if (options.synchronousTimer) callback(); return handle; }, }, @@ -517,7 +526,7 @@ describe('Prebid selection coordination', () => { prebidBid: bid, }) ).toMatchObject({ ok: true }); - expect(coordinator.track(prepared, navigation)).toBe(!synchronousTimer); + expect(coordinator.track(prepared, navigation)).toBe(!options.synchronousTimer); return prepared; }; return { @@ -573,7 +582,7 @@ describe('Prebid selection coordination', () => { }); it('tombstones a selected reservation when its PUC attempt cannot activate', () => { - const harness = prepareSelection(false); + const harness = prepareSelection({ activateResult: false }); const selected = harness.admitted('f'); harness.coordinator.auctionEnded( @@ -625,6 +634,38 @@ describe('Prebid selection coordination', () => { 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'); @@ -646,7 +687,7 @@ describe('Prebid selection coordination', () => { }); it('rolls back a scheduler that invokes the deadline before timer publication returns', () => { - const harness = prepareSelection(true, true); + const harness = prepareSelection({ synchronousTimer: true }); const bid = harness.admitted('g'); expect(harness.reservations.recognize(bid.bid.adId)).toMatchObject({ @@ -656,4 +697,40 @@ describe('Prebid selection coordination', () => { 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(); + }); }); From 94894999801e99aba2b0be4964d73a10262db49e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:03:27 -0700 Subject: [PATCH 100/194] Complete GPT handoff startup integration --- .../lib/src/adapters/googletag.ts | 60 +++- .../lib/src/composition/browser.ts | 13 +- .../lib/src/integrations/gpt/module.ts | 22 +- .../lib/src/integrations/gpt/startup.ts | 53 +++ .../lib/src/services/slots.ts | 340 +++++++++++++++--- .../lib/test/adapters/googletag.test.ts | 74 ++++ .../lib/test/composition/browser.test.ts | 119 ++++++ .../lib/test/integrations/gpt/module.test.ts | 62 +++- .../lib/test/integrations/gpt/startup.test.ts | 58 +++ .../lib/test/services/slots.test.ts | 130 +++++++ 10 files changed, 861 insertions(+), 70 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/gpt/startup.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index 08d461eda..0889fb019 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -1799,16 +1799,6 @@ export function createBrowserGoogletagAdapter( if (typeof observer !== 'object' || observer === null) { throw new TypeError('GPT publisher observer must be an object'); } - const current = currentBinding(); - 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 observerMethod = ( key: Key ): GoogletagPublisherCallObserver[Key] | undefined => { @@ -1826,6 +1816,56 @@ export function createBrowserGoogletagAdapter( 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 && diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 1df7a28a6..3b4539f80 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -41,6 +41,7 @@ import { type GptWinnerPublicationInput, type GptWinnerPublicationResult, } from '../integrations/gpt/module'; +import { createGptStartup } from '../integrations/gpt/startup'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; import { createRuntimeSession } from '../kernel/sessions'; @@ -253,8 +254,17 @@ export function createTestBrowserRuntimeComposition( ): BrowserRuntimeComposition { const composition = createBrowserComposition(compositionOptions); const providedBindings = runtimeOptions.getBindings; + let browserServices: Readonly | undefined; const startGpt = compositionOptions.gptStartupForTest ?? (() => undefined); - const gptRuntime = Object.freeze({ start: startGpt }); + 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 startPrebid = compositionOptions.prebidStartupForTest ?? (() => undefined); const prebidRuntime = Object.freeze({ start: startPrebid }); let runtimeSession: RuntimeSession | undefined; @@ -274,7 +284,6 @@ export function createTestBrowserRuntimeComposition( }); }; let preparedBrowserServices: PreparedBrowserServices | undefined; - let browserServices: Readonly | undefined; let auctionContextRegistry: AuctionContextRegistry | undefined; let auctionBatchService: AuctionBatchService | undefined; let projectionParser: ((candidate: unknown) => object | undefined) | undefined; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts index 4b1bfa27e..52d0f62c5 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -45,6 +45,7 @@ const promiseThenIntrinsic = Promise.prototype.then; const reflectApplyIntrinsic = Reflect.apply; interface GptIntegrationRuntime { + readonly activate: () => () => void; readonly start: (config: unknown) => void; } @@ -585,12 +586,22 @@ function readGptRuntime( candidate === null || Array.isArray(candidate) || !Object.isFrozen(candidate) || - Reflect.ownKeys(candidate).length !== 1 + Reflect.ownKeys(candidate).length !== 2 ) { return undefined; } + const activate = Object.getOwnPropertyDescriptor(candidate, 'activate'); const start = Object.getOwnPropertyDescriptor(candidate, 'start'); - if (!start || !('value' in start) || typeof start.value !== 'function') return undefined; + if ( + !activate || + !('value' in activate) || + typeof activate.value !== 'function' || + !start || + !('value' in start) || + typeof start.value !== 'function' + ) { + return undefined; + } return candidate as GptIntegrationRuntime; } @@ -608,6 +619,13 @@ export function createGptIntegrationRegistration(release: string): IntegrationRe activate: ({ afterCommit, onDispose }: IntegrationActivationContext) => { // Register restoration before the first live browser mutation. onDispose(resetGuardState); + const runtimeRelease: { value?: () => void } = {}; + onDispose(() => runtimeRelease.value?.()); + const release = runtime.activate(); + if (typeof release !== 'function') { + throw new TypeError('GPT integration activation disposer is unavailable'); + } + runtimeRelease.value = release; installGptGuard(); afterCommit(() => runtime.start(config)); }, diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts b/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts new file mode 100644 index 000000000..d0f92278b --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts @@ -0,0 +1,53 @@ +import type { + GoogletagAdapter, + GoogletagPublisherCallObserver, + GoogletagPublisherDefineSlotCall, + GoogletagPublisherDestroySlotsCall, + GoogletagPublisherDisplayCall, + GoogletagPublisherRefreshCall, +} from '../../adapters/googletag'; +import type { SlotService } from '../../services/slots'; + +type GptPublisherSlotBoundary = Pick< + SlotService, + | 'claimPublisherGptSlot' + | 'preparePublisherDisplay' + | 'preparePublisherRefresh' + | 'recordPublisherDestruction' +>; + +export interface GptStartup { + readonly activate: () => () => void; + readonly start: (config: unknown) => void; +} + +export interface GptStartupOptions { + readonly googletag: Pick; + readonly slots: () => GptPublisherSlotBoundary; + readonly start?: (config: unknown) => void; +} + +/** Join the sole GPT interception boundary to runtime-owned slot handoff state. */ +export function createGptStartup(options: GptStartupOptions): GptStartup { + return Object.freeze({ + activate: (): (() => void) => { + const slots = options.slots(); + const observer: GoogletagPublisherCallObserver = Object.freeze({ + defineSlot: (call: Readonly) => + slots.claimPublisherGptSlot(call), + destroySlots: ({ slots: destroyed }: Readonly) => { + for (let index = 0; index < destroyed.length; index += 1) { + const slot = destroyed[index]; + if (slot) slots.recordPublisherDestruction(slot); + } + }, + display: (call: Readonly) => + slots.preparePublisherDisplay(call), + refresh: (call: Readonly) => + slots.preparePublisherRefresh(call), + }); + return options.googletag.observePublisherCalls(observer); + }, + start: (config: unknown): void => options.start?.(config), + }); +} diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index f4e2ade95..c0ee6b796 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -2,6 +2,9 @@ import type { GoogletagAdapter, GoogletagFacade, GoogletagOperation, + GoogletagPublisherDefineSlotCall, + GoogletagPublisherDisplayCall, + GoogletagPublisherRefreshCall, GoogletagReplacementCommitAdmission, GoogletagReplacementDefinition, GoogletagReplacementResult, @@ -60,6 +63,8 @@ export type SlotRegistrationResult = /** Binding metadata required for safe TS-owned replacement. */ export interface GptSlotBinding { readonly definition?: GoogletagReplacementDefinition; + /** Stable configured prefix accepted only for an unambiguous hydration handoff. */ + readonly elementIdPrefix?: string; readonly ownership: GptSlotOwnership; readonly slot: object; } @@ -142,6 +147,18 @@ export interface SlotService { owner: NavigationSession, slots: readonly string[] ) => PreparedProjectionSlots | undefined; + readonly claimPublisherGptSlot: ( + call: GoogletagPublisherDefineSlotCall + ) => Readonly<{ action: 'forward' }> | Readonly<{ action: 'handoff'; slot: object }>; + readonly preparePublisherDisplay: ( + call: GoogletagPublisherDisplayCall + ) => Readonly<{ action: 'forward' }> | Readonly<{ action: 'suppress' }>; + readonly preparePublisherRefresh: ( + call: GoogletagPublisherRefreshCall + ) => + | Readonly<{ action: 'forward' }> + | Readonly<{ action: 'replace'; slots: readonly object[] }> + | Readonly<{ action: 'suppress' }>; readonly projectionRegistry: (owner: NavigationSession) => ProjectionSlotRegistry; readonly recordPublisherDestruction: (slot: object) => boolean; readonly recordPublisherIntent: (slot: object) => boolean; @@ -210,10 +227,14 @@ interface PhysicalSlot { artifactRetirementAttempted: boolean; definition: GoogletagReplacementDefinition | undefined; domElement: object | undefined; + elementIdPrefix: string | undefined; lastResponseIdentifier: string | undefined; ownership: GptSlotOwnership; placementKeys: readonly string[]; publisherIntentCount: number; + publisherElementIds: readonly string[]; + suppressPublisherDisplay: boolean; + suppressPublisherRefresh: boolean; quarantineReason: 'completion' | 'navigation' | 'request' | undefined; record: InternalSlotRecord | undefined; saturationOwner: boolean; @@ -489,6 +510,30 @@ function copyReplacementSizes(sizes: unknown): unknown | undefined { } } +function replacementSizesEqual(left: unknown, right: unknown): boolean { + const leftCopy = copyReplacementSizes(left); + const rightCopy = copyReplacementSizes(right); + if (!Array.isArray(leftCopy) || !Array.isArray(rightCopy)) return false; + const pair = (value: unknown): value is readonly [number, number] => + Array.isArray(value) && + value.length === 2 && + typeof value[0] === 'number' && + typeof value[1] === 'number'; + const normalized = (value: readonly unknown[]): readonly (readonly [number, number])[] => + pair(value) ? Object.freeze([value]) : (value as readonly (readonly [number, number])[]); + const leftPairs = normalized(leftCopy); + const rightPairs = normalized(rightCopy); + if (leftPairs.length !== rightPairs.length) return false; + for (let index = 0; index < leftPairs.length; index += 1) { + const leftPair = leftPairs[index]; + const rightPair = rightPairs[index]; + if (!leftPair || !rightPair || leftPair[0] !== rightPair[0] || leftPair[1] !== rightPair[1]) { + return false; + } + } + return true; +} + function snapshotReplacementDefinition(input: unknown): GoogletagReplacementDefinition | undefined { if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; let adUnitPath: unknown; @@ -888,16 +933,20 @@ export function createSlotService(options: SlotServiceOptions): SlotService { artifactRetirementAttempted: false, definition, domElement, + elementIdPrefix: oldPhysical.elementIdPrefix, destroyAttempted: false, lastResponseIdentifier: undefined, ownership: 'trusted_server', placementKeys: oldPhysical.placementKeys, publisherIntentCount: 0, + publisherElementIds: Object.freeze([]), quarantineReason: undefined, record, saturationOwner: false, slot: replacement, state: 'live', + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, }; let committed = false; const rollback = (): void => { @@ -949,16 +998,20 @@ export function createSlotService(options: SlotServiceOptions): SlotService { artifactRetirementAttempted: true, definition: source.definition, domElement: undefined, + elementIdPrefix: source.elementIdPrefix, destroyAttempted: true, lastResponseIdentifier: undefined, ownership: 'trusted_server', placementKeys: source.placementKeys, publisherIntentCount: 0, + publisherElementIds: Object.freeze([]), quarantineReason: 'request', record: undefined, saturationOwner: false, slot: orphanedSlot, state: 'quarantined', + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, }; try { setWeakMapValue(physicalByObject, orphan.slot, orphan); @@ -1978,10 +2031,12 @@ export function createSlotService(options: SlotServiceOptions): SlotService { let slot: unknown; let ownership: unknown; let externalDefinition: unknown; + let externalElementIdPrefix: unknown; try { slot = binding.slot; ownership = binding.ownership; externalDefinition = binding.definition; + externalElementIdPrefix = binding.elementIdPrefix; } catch { return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); } @@ -1994,6 +2049,13 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (ownership !== 'publisher' && ownership !== 'trusted_server') { return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); } + if ( + externalElementIdPrefix !== undefined && + (typeof externalElementIdPrefix !== 'string' || !validSlotIdentity(externalElementIdPrefix)) + ) { + return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); + } + const elementIdPrefix = externalElementIdPrefix as string | undefined; const definition = externalDefinition === undefined ? undefined @@ -2044,7 +2106,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const previousOwnership = existing.ownership; const previousDefinition = existing.definition; const previousDomElement = existing.domElement; + const previousElementIdPrefix = existing.elementIdPrefix; const previousPlacementKeys = existing.placementKeys; + const previousPublisherElementIds = existing.publisherElementIds; try { if (!wasStrong) addSetValue(physicalSlots, existing); if (!setHasValue(physicalSlots, existing)) throw new Error('physical publication failed'); @@ -2053,7 +2117,12 @@ export function createSlotService(options: SlotServiceOptions): SlotService { existing.ownership = ownership; existing.definition = definition; existing.domElement = domElement; + existing.elementIdPrefix = elementIdPrefix; existing.placementKeys = bindingPlacementKeys; + existing.publisherElementIds = + ownership === 'publisher' && definition + ? Object.freeze([definition.elementId]) + : Object.freeze([]); record.physical = existing; if (ownership === 'publisher') cancelReconciliation(record); return Object.freeze({ ok: true }); @@ -2063,7 +2132,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { existing.ownership = previousOwnership; existing.definition = previousDefinition; existing.domElement = previousDomElement; + existing.elementIdPrefix = previousElementIdPrefix; existing.placementKeys = previousPlacementKeys; + existing.publisherElementIds = previousPublisherElementIds; if (!wasStrong) deleteSetValue(physicalSlots, existing); return Object.freeze({ ok: false, reason: 'stale_owner' }); } @@ -2076,16 +2147,23 @@ export function createSlotService(options: SlotServiceOptions): SlotService { artifactRetirementAttempted: false, definition, domElement, + elementIdPrefix, destroyAttempted: false, lastResponseIdentifier: undefined, ownership, placementKeys: bindingPlacementKeys, publisherIntentCount: 0, + publisherElementIds: + ownership === 'publisher' && definition + ? Object.freeze([definition.elementId]) + : Object.freeze([]), quarantineReason: undefined, record, saturationOwner: false, slot: slotObject, state: 'live', + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, }; try { setWeakMapValue(physicalByObject, slotObject, physical); @@ -2471,6 +2549,212 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return Object.freeze(handles); }; + const recordPublisherDestruction = (slot: object): boolean => { + const physical = weakMapValue(physicalByObject, slot); + if (!physical) return false; + const record = physical.record; + if (record) cancelReconciliation(record); + const cycleIntent = physical.activeCycle?.intent; + if (cycleIntent && !cycleIntent.terminal) settle(cycleIntent, failed('gpt_request_failed')); + if (record?.activeIntent) settle(record.activeIntent, failed('gpt_request_failed')); + if (record?.queuedIntent) settle(record.queuedIntent, failed('gpt_request_failed')); + if (record) retireCommittedArtifact(record, physical); + if (record?.physical === physical) record.physical = undefined; + physical.record = undefined; + physical.activeCycle = undefined; + physical.publisherIntentCount = 0; + physical.publisherElementIds = Object.freeze([]); + physical.suppressPublisherDisplay = false; + physical.suppressPublisherRefresh = false; + physical.state = 'retired'; + releasePhysicalPlacement(physical); + deleteSetValue(physicalSlots, physical); + if (weakMapValue(physicalByObject, slot) === physical) { + deleteWeakMapValue(physicalByObject, slot); + } + return true; + }; + + const recordPublisherIntent = (slot: object): boolean => { + const physical = weakMapValue(physicalByObject, slot); + if (!physical || (physical.state !== 'live' && !physical.activeCycle)) return false; + if (physical.publisherIntentCount >= MAX_PENDING_PUBLISHER_INTENTS) { + physical.state = 'quarantined'; + physical.quarantineReason = 'request'; + quarantinePhysicalPlacement(physical); + if (physical.record?.activeIntent) { + settle(physical.record.activeIntent, failed('cycle_unattributable')); + } + if (physical.record?.queuedIntent) { + settle(physical.record.queuedIntent, failed('cycle_unattributable')); + } + return false; + } + if (physical.record?.activeIntent) { + settle(physical.record.activeIntent, failed('cycle_unattributable')); + } + if (physical.record?.queuedIntent) { + settle(physical.record.queuedIntent, failed('cycle_unattributable')); + } + physical.publisherIntentCount += 1; + if (physical.activeCycle?.kind === 'trusted_server') { + physical.activeCycle = { intent: undefined, kind: 'publisher' }; + physical.state = 'quarantined'; + physical.quarantineReason = 'completion'; + } + return true; + }; + + const publisherPhysicalForTarget = (target: unknown): PhysicalSlot | undefined => { + if ((typeof target === 'object' && target !== null) || typeof target === 'function') { + const exact = weakMapValue(physicalByObject, target as object); + return exact?.ownership === 'publisher' && exact.state === 'live' ? exact : undefined; + } + if (typeof target !== 'string') return undefined; + let match: PhysicalSlot | undefined; + for (const candidate of setValueSnapshot(physicalSlots)) { + if (candidate.ownership !== 'publisher' || candidate.state !== 'live') continue; + let matches = false; + for (let index = 0; index < candidate.publisherElementIds.length; index += 1) { + if (candidate.publisherElementIds[index] === target) { + matches = true; + break; + } + } + if (!matches) continue; + if (match && match !== candidate) return undefined; + match = candidate; + } + return match; + }; + + const claimPublisherGptSlot = ( + call: GoogletagPublisherDefineSlotCall + ): Readonly<{ action: 'forward' }> | Readonly<{ action: 'handoff'; slot: object }> => { + let elementId: unknown; + let adUnitPath: unknown; + let sizes: unknown; + let initialLoadDisabled: unknown; + try { + elementId = call.elementId; + adUnitPath = call.adUnitPath; + sizes = call.sizes; + initialLoadDisabled = call.initialLoadDisabled; + } catch { + return Object.freeze({ action: 'forward' }); + } + if (typeof elementId !== 'string' || !validSlotIdentity(elementId)) { + return Object.freeze({ action: 'forward' }); + } + const exact: PhysicalSlot[] = []; + const hydration: PhysicalSlot[] = []; + for (const physical of setValueSnapshot(physicalSlots)) { + const record = physical.record; + const definition = physical.definition; + if ( + physical.ownership !== 'trusted_server' || + physical.state !== 'live' || + !record || + !record.state.owner.isCurrent() || + !definition + ) { + continue; + } + if (definition.elementId === elementId) { + exact[exact.length] = physical; + continue; + } + if ( + physical.elementIdPrefix && + elementId.startsWith(physical.elementIdPrefix) && + !reconciliationElementConnected(physical.domElement) && + adUnitPath === definition.adUnitPath && + replacementSizesEqual(sizes, definition.sizes) + ) { + hydration[hydration.length] = physical; + } + } + const matches = exact.length > 0 ? exact : hydration; + if (matches.length !== 1) return Object.freeze({ action: 'forward' }); + const physical = matches[0]; + const record = physical?.record; + if (!physical || !record || !record.state.owner.isCurrent()) { + return Object.freeze({ action: 'forward' }); + } + const definitionElementId = physical.definition?.elementId; + const aliases = + definitionElementId === undefined || definitionElementId === elementId + ? Object.freeze([elementId]) + : Object.freeze([definitionElementId, elementId]); + cancelReconciliation(record); + if ( + physical.record !== record || + record.physical !== physical || + physical.state !== 'live' || + !record.state.owner.isCurrent() + ) { + return Object.freeze({ action: 'forward' }); + } + physical.ownership = 'publisher'; + physical.publisherElementIds = aliases; + physical.suppressPublisherDisplay = true; + physical.suppressPublisherRefresh = initialLoadDisabled === true; + return Object.freeze({ action: 'handoff', slot: physical.slot }); + }; + + const preparePublisherDisplay = ( + call: GoogletagPublisherDisplayCall + ): Readonly<{ action: 'forward' }> | Readonly<{ action: 'suppress' }> => { + let target: unknown; + let initialLoadDisabled: unknown; + try { + target = call.target; + initialLoadDisabled = call.initialLoadDisabled; + } catch { + return Object.freeze({ action: 'forward' }); + } + const physical = publisherPhysicalForTarget(target); + if (!physical) return Object.freeze({ action: 'forward' }); + if (physical.suppressPublisherDisplay) { + physical.suppressPublisherDisplay = false; + return Object.freeze({ action: 'suppress' }); + } + if (initialLoadDisabled !== true) recordPublisherIntent(physical.slot); + return Object.freeze({ action: 'forward' }); + }; + + const preparePublisherRefresh = ( + call: GoogletagPublisherRefreshCall + ): + | Readonly<{ action: 'forward' }> + | Readonly<{ action: 'replace'; slots: readonly object[] }> + | Readonly<{ action: 'suppress' }> => { + let slots: readonly object[]; + try { + slots = call.slots; + } catch { + return Object.freeze({ action: 'forward' }); + } + if (!Array.isArray(slots)) return Object.freeze({ action: 'forward' }); + let suppressed = false; + const forwarded: object[] = []; + for (let index = 0; index < slots.length; index += 1) { + const slot = slots[index]; + if (!slot) continue; + const physical = weakMapValue(physicalByObject, slot); + if (physical?.ownership === 'publisher' && physical.suppressPublisherRefresh) { + physical.suppressPublisherRefresh = false; + suppressed = true; + continue; + } + forwarded[forwarded.length] = slot; + if (physical?.ownership === 'publisher') recordPublisherIntent(slot); + } + if (!suppressed) return Object.freeze({ action: 'forward' }); + if (forwarded.length === 0) return Object.freeze({ action: 'suppress' }); + return Object.freeze({ action: 'replace', slots: Object.freeze(forwarded) }); + }; + const service: SlotService = Object.freeze({ activate: (): GoogletagOperation => { if (activation) return activation; @@ -2589,6 +2873,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }, }); }, + claimPublisherGptSlot, + preparePublisherDisplay, + preparePublisherRefresh, projectionRegistry: (owner: NavigationSession): ProjectionSlotRegistry => Object.freeze({ prepareProjectionSlots: ( @@ -2605,57 +2892,8 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return service.prepareProjectionSlots(owner, slots); }, }), - recordPublisherDestruction: (slot: object): boolean => { - const physical = weakMapValue(physicalByObject, slot); - if (!physical) return false; - const record = physical.record; - if (record) cancelReconciliation(record); - const cycleIntent = physical.activeCycle?.intent; - if (cycleIntent && !cycleIntent.terminal) settle(cycleIntent, failed('gpt_request_failed')); - if (record?.activeIntent) settle(record.activeIntent, failed('gpt_request_failed')); - if (record?.queuedIntent) settle(record.queuedIntent, failed('gpt_request_failed')); - if (record) retireCommittedArtifact(record, physical); - if (record?.physical === physical) record.physical = undefined; - physical.record = undefined; - physical.activeCycle = undefined; - physical.publisherIntentCount = 0; - physical.state = 'retired'; - releasePhysicalPlacement(physical); - deleteSetValue(physicalSlots, physical); - if (weakMapValue(physicalByObject, slot) === physical) { - deleteWeakMapValue(physicalByObject, slot); - } - return true; - }, - recordPublisherIntent: (slot: object): boolean => { - const physical = weakMapValue(physicalByObject, slot); - if (!physical || (physical.state !== 'live' && !physical.activeCycle)) return false; - if (physical.publisherIntentCount >= MAX_PENDING_PUBLISHER_INTENTS) { - physical.state = 'quarantined'; - physical.quarantineReason = 'request'; - quarantinePhysicalPlacement(physical); - if (physical.record?.activeIntent) { - settle(physical.record.activeIntent, failed('cycle_unattributable')); - } - if (physical.record?.queuedIntent) { - settle(physical.record.queuedIntent, failed('cycle_unattributable')); - } - return false; - } - if (physical.record?.activeIntent) { - settle(physical.record.activeIntent, failed('cycle_unattributable')); - } - if (physical.record?.queuedIntent) { - settle(physical.record.queuedIntent, failed('cycle_unattributable')); - } - physical.publisherIntentCount += 1; - if (physical.activeCycle?.kind === 'trusted_server') { - physical.activeCycle = { intent: undefined, kind: 'publisher' }; - physical.state = 'quarantined'; - physical.quarantineReason = 'completion'; - } - return true; - }, + recordPublisherDestruction, + recordPublisherIntent, registeredSlotIdsForTest: (): readonly string[] => { const records = mapValueSnapshot(registeredSlots); records.sort((left, right) => left.view.ordinal - right.view.ordinal); diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index dce6d1c01..82cdd3edd 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -1942,11 +1942,80 @@ describe('browser googletag adapter readiness', () => { expect(ready.pubads.refresh).toBe(nativeRefresh); }); + it('installs the publisher observer when an accepted command-queue stub becomes ready', () => { + const commands: Array<() => void> = []; + const pending = { + cmd: { + push: vi.fn((callback: () => void) => { + commands.push(callback); + return commands.length; + }), + }, + }; + const target = { googletag: pending as object }; + const adapter = createBrowserGoogletagAdapter(target); + const handoff = {}; + const release = adapter.observePublisherCalls({ + defineSlot: () => Object.freeze({ action: 'handoff', slot: handoff }), + }); + const ready = createReadyGoogletag(); + const nativeDefineSlot = vi.fn((_path: string, _sizes: unknown, _elementId: string) => ({})); + Object.assign(pending, { + apiReady: true, + defineSlot: nativeDefineSlot, + destroySlots: ready.googletag.destroySlots, + display: ready.googletag.display, + getConfig: ready.googletag.getConfig, + pubads: ready.googletag.pubads, + pubadsReady: true, + setConfig: ready.googletag.setConfig, + }); + + expect(commands).toHaveLength(1); + commands[0]?.(); + const defineSlot = (pending as typeof pending & { defineSlot: typeof nativeDefineSlot }) + .defineSlot; + expect(defineSlot).not.toBe(nativeDefineSlot); + expect(defineSlot('/publisher', [300, 250], 'slot')).toBe(handoff); + expect(nativeDefineSlot).not.toHaveBeenCalled(); + + release(); + expect((pending as typeof pending & { defineSlot: typeof nativeDefineSlot }).defineSlot).toBe( + nativeDefineSlot + ); + }); + + it('does not classify facade-driven GPT calls as publisher calls', async () => { + const ready = createReadyGoogletag(); + const nativeRefresh = ready.pubads.refresh; + const observer = { + display: vi.fn(() => Object.freeze({ action: 'suppress' as const })), + refresh: vi.fn(() => Object.freeze({ action: 'suppress' as const })), + }; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls(observer); + + await expect( + adapter.run((gpt) => { + gpt.display('trusted-slot'); + gpt.refresh([], { changeCorrelator: false }); + }).result + ).resolves.toBeUndefined(); + + expect(observer.display).not.toHaveBeenCalled(); + expect(observer.refresh).not.toHaveBeenCalled(); + expect(ready.display).toHaveBeenCalledExactlyOnceWith('trusted-slot'); + expect(nativeRefresh).toHaveBeenCalledExactlyOnceWith([], { + changeCorrelator: false, + }); + }); + it('mediates only explicit publisher decisions and preserves receiver, arguments, return, throw, and order', () => { const ready = createReadyGoogletag({ initialLoadDisabled: true }); const handoffSlot = Object.freeze({ id: 'handoff' }); const ordinarySlot = Object.freeze({ id: 'ordinary' }); const refreshOptions = Object.freeze({ changeCorrelator: true, publisher: 'kept' }); + const publisherError = new Error('publisher display failed'); const defineReceiver = Object.freeze({ receiver: 'define' }); const refreshReceiver = Object.freeze({ receiver: 'refresh' }); const order: string[] = []; @@ -1956,6 +2025,7 @@ describe('browser googletag adapter readiness', () => { }); const nativeDisplay = vi.fn(function (this: unknown, ...arguments_: unknown[]) { order.push('native:display'); + if (arguments_[0] === 'throw') throw publisherError; return Object.freeze({ arguments_, receiver: this }); }); const nativeRefresh = vi.fn(function (this: unknown, ...arguments_: unknown[]) { @@ -2030,6 +2100,9 @@ describe('browser googletag adapter readiness', () => { arguments_: ['handoff-id', 'publisher-extra'], receiver: defineReceiver, }); + expect(() => Reflect.apply(display, defineReceiver, ['throw', 'publisher-extra'])).toThrow( + publisherError + ); const refresh = ready.pubads.refresh as (...arguments_: unknown[]) => unknown; expect(Reflect.apply(refresh, refreshReceiver, [undefined, refreshOptions])).toEqual({ @@ -2047,6 +2120,7 @@ describe('browser googletag adapter readiness', () => { 'native:define', 'observer:display', 'native:display', + 'native:display', 'observer:refresh', 'native:refresh', 'native:destroy', diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 3df0f52fa..7ab2c9811 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + createBrowserGoogletagAdapter, createNoopGoogletagAdapter, type GoogletagAdapter, type GoogletagBindingStatus, @@ -673,6 +674,124 @@ describe('browser composition', () => { expect(isGuardInstalled()).toBe(false); }); + 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: { version: 1, releaseId, integrations: [{ id: 'gpt', required: true }] }, + knownIntegrationIds: Object.freeze(['gpt']), + boot: { + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'slot', outcome: 'no_bid' }], + }, + 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, 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 index 878a3a4ac..4f9e6b0ce 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -141,6 +141,11 @@ describe('transactional GPT integration module', () => { 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; @@ -153,7 +158,7 @@ describe('transactional GPT integration module', () => { now: () => 0, getBindings: () => ({ config, - interfaces: Object.freeze({ gpt: Object.freeze({ start }) }), + interfaces: Object.freeze({ gpt: Object.freeze({ activate, start }) }), }), }); registry.register(createGptIntegrationRegistration(RELEASE_ID)); @@ -179,7 +184,16 @@ describe('transactional GPT integration module', () => { expect(result).toMatchObject({ state: 'kernel' }); expect(isGuardInstalled()).toBe(true); expect(document.write).not.toBe(originalDocumentWrite); - expect(order).toEqual(['gate:prepare', 'core', 'gate:activate', 'publish', 'start', 'drain']); + 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') { @@ -188,6 +202,7 @@ describe('transactional GPT integration module', () => { } 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 () => { @@ -200,7 +215,9 @@ describe('transactional GPT integration module', () => { now: () => 0, getBindings: () => ({ config: Object.freeze({}), - interfaces: Object.freeze({ gpt: Object.freeze({ start }) }), + interfaces: Object.freeze({ + gpt: Object.freeze({ activate: () => vi.fn(), start }), + }), }), }); registry.register(createGptIntegrationRegistration(RELEASE_ID)); @@ -222,6 +239,37 @@ describe('transactional GPT integration module', () => { 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']), @@ -263,7 +311,9 @@ describe('transactional GPT integration module', () => { now: () => 0, getBindings: () => ({ config, - interfaces: Object.freeze({ gpt: Object.freeze({ start }) }), + interfaces: Object.freeze({ + gpt: Object.freeze({ activate: () => vi.fn(), start }), + }), }), }); registry.register(createGptIntegrationRegistration(RELEASE_ID)); @@ -290,7 +340,9 @@ describe('transactional GPT integration module', () => { onRuntimeFailure: (failure) => runtimeFailures.push(failure), getBindings: () => ({ config: Object.freeze({}), - interfaces: Object.freeze({ gpt: Object.freeze({ start }) }), + interfaces: Object.freeze({ + gpt: Object.freeze({ activate: () => vi.fn(), start }), + }), }), }); registry.register(createGptIntegrationRegistration(RELEASE_ID)); 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..9de10d2f9 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + GoogletagAdapter, + GoogletagPublisherCallObserver, +} from '../../../src/adapters/googletag'; +import { createGptStartup } from '../../../src/integrations/gpt/startup'; +import type { SlotService } from '../../../src/services/slots'; + +describe('GPT startup bridge', () => { + it('installs one reversible typed observer and delegates all handoff state to slots', () => { + 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), + }) satisfies Pick< + SlotService, + | 'claimPublisherGptSlot' + | 'preparePublisherDisplay' + | 'preparePublisherRefresh' + | 'recordPublisherDestruction' + >; + const start = vi.fn(); + const startup = createGptStartup({ googletag: adapter, slots: () => slots, start }); + + expect(startup.activate()).toBe(release); + 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]) }) + ).toEqual({ action: 'suppress' }); + observer?.destroySlots?.({ slots: Object.freeze([slot, {}]) }); + expect(slots.recordPublisherDestruction).toHaveBeenCalledTimes(2); + + const config = Object.freeze({ disableInitialLoad: true }); + startup.start(config); + expect(start).toHaveBeenCalledExactlyOnceWith(config); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 1f260c66d..642907875 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -484,6 +484,136 @@ describe('slot registry', () => { 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 service = createSlotService({ googletag: gpt.adapter }); + 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( + 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] }); + expect( + service.preparePublisherRefresh({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + }) + ).toEqual({ action: 'forward' }); + + 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('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 service = createSlotService({ + googletag: createGptHarness().adapter, + reconciliation: dom.boundary, + }); + 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 }); + }); + + 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' }); + expect( + service.preparePublisherRefresh({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + }) + ).toEqual({ action: 'forward' }); + }); + it('uses captured Set validation intrinsics on a hostile page', () => { const service = createSlotService({ googletag: createGptHarness().adapter }); const navigation = createNavigation(); From acbc22169b1ad319943d39475572c4b6f3989036 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:08:51 -0700 Subject: [PATCH 101/194] Verify Prebid admission against the real artifact --- .../lib/build-prebid-external.mjs | 2 +- .../lib/src/adapters/prebid.ts | 76 ++++++++++++---- .../lib/test/adapters/prebid.test.ts | 54 +++++++++-- .../test/prebid-artifact-integration.test.mjs | 90 +++++++++++++++++++ 4 files changed, 197 insertions(+), 25 deletions(-) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index 1aaebb606..7f972da5a 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -312,7 +312,7 @@ function renderExternalWrapper(bundleCode, stamp) { `var __tsExistingWindow=window;var __tsExisting=__tsExistingWindow.pbjs;var __tsExistingDescriptor;try{__tsExistingDescriptor=__tsExisting&&Object.getOwnPropertyDescriptor(__tsExisting,"${ARTIFACT_PROPERTY}");}catch(_){__tsExistingDescriptor=undefined;}`, 'if(__tsExistingDescriptor&&Object.prototype.hasOwnProperty.call(__tsExistingDescriptor,"value")&&__tsExistingDescriptor.enumerable===false&&__tsExistingDescriptor.writable===false&&__tsExistingDescriptor.configurable===false&&__tsValidStamp(__tsExistingDescriptor.value)){if(__tsEqual(__tsExistingDescriptor.value,__tsStamp))return;__tsWarn();return;}', bundleCode, - `var __tsPbjs=window.pbjs;var __tsRequired=["addAdUnits","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids"];var __tsReady=!!__tsPbjs;for(var __tsIndex=0;__tsReady&&__tsIndex<__tsRequired.length;__tsIndex+=1)__tsReady=typeof __tsPbjs[__tsRequired[__tsIndex]]==="function";if(__tsReady){var __tsAfter;var __tsInherited=false;try{__tsAfter=Object.getOwnPropertyDescriptor(__tsPbjs,"${ARTIFACT_PROPERTY}");__tsInherited=!__tsAfter&&Reflect.has(__tsPbjs,"${ARTIFACT_PROPERTY}");}catch(_){__tsAfter=undefined;__tsInherited=true;}if(!__tsAfter&&!__tsInherited){try{Object.defineProperty(__tsPbjs,"${ARTIFACT_PROPERTY}",{value:__tsStamp,enumerable:false,writable:false,configurable:false});}catch(_){__tsWarn();}}else if(__tsInherited||!Object.prototype.hasOwnProperty.call(__tsAfter,"value")||!__tsEqual(__tsAfter.value,__tsStamp)){__tsWarn();}}`, + `var __tsPbjs=window.pbjs;var __tsRequired=["addAdUnits","getBidResponsesForAdUnitCode","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids"];var __tsReady=!!__tsPbjs;for(var __tsIndex=0;__tsReady&&__tsIndex<__tsRequired.length;__tsIndex+=1)__tsReady=typeof __tsPbjs[__tsRequired[__tsIndex]]==="function";if(__tsReady){var __tsAfter;var __tsInherited=false;try{__tsAfter=Object.getOwnPropertyDescriptor(__tsPbjs,"${ARTIFACT_PROPERTY}");__tsInherited=!__tsAfter&&Reflect.has(__tsPbjs,"${ARTIFACT_PROPERTY}");}catch(_){__tsAfter=undefined;__tsInherited=true;}if(!__tsAfter&&!__tsInherited){try{Object.defineProperty(__tsPbjs,"${ARTIFACT_PROPERTY}",{value:__tsStamp,enumerable:false,writable:false,configurable:false});}catch(_){__tsWarn();}}else if(__tsInherited||!Object.prototype.hasOwnProperty.call(__tsAfter,"value")||!__tsEqual(__tsAfter.value,__tsStamp)){__tsWarn();}}`, '})();', '', ].join('\n'); diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index bee06c111..123022701 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -207,7 +207,7 @@ interface PendingOperation { interface ActiveTrustedServerAdmission { readonly addBidResponse: (...arguments_: unknown[]) => unknown; readonly binding: PresentPrebid; - readonly requests: readonly PrebidTrustedServerBidRequestV1[]; + readonly requests: readonly CapturedTrustedServerBidRequest[]; readonly admittedIds: Set; readonly admittedRequests: Set; readonly attemptedRequests: Set; @@ -216,6 +216,11 @@ interface ActiveTrustedServerAdmission { complete(): void; } +interface CapturedTrustedServerBidRequest extends PrebidTrustedServerBidRequestV1 { + readonly adUnitId: string; + readonly transactionId: string; +} + const encoder = new TextEncoder(); function validUnicodeScalars(value: string): boolean { @@ -703,6 +708,7 @@ export function createBrowserPrebidAdapter( | Readonly<{ auctionId: string; bids: readonly PrebidTrustedServerBidRequestV1[]; + requests: readonly CapturedTrustedServerBidRequest[]; }> | undefined => { try { @@ -723,29 +729,58 @@ export function createBrowserPrebidAdapter( ) { return undefined; } - const requests: PrebidTrustedServerBidRequestV1[] = []; + 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) + !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); - requests.push(Object.freeze({ adUnitCode: adUnitCode.value, requestId: requestId.value })); + 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(requests) }); + return Object.freeze({ + auctionId: auctionId.value, + bids: Object.freeze(bids), + requests: Object.freeze(requests), + }); } catch { return undefined; } @@ -753,23 +788,25 @@ export function createBrowserPrebidAdapter( const responseCount = ( binding: PresentPrebid, + auctionId: string, adUnitCode: string, adId: string, requestId: string, isCurrent: () => boolean ): number => { const response = callBound(binding, 'getBidResponsesForAdUnitCode', [adUnitCode], isCurrent); - if (typeof response !== 'object' || response === null || Array.isArray(response)) { + if (!Array.isArray(response)) { throw new PrebidAdapterError('external_artifact_incompatible'); } const bids = safeMember(response, 'bids'); - if (!Array.isArray(bids)) throw new PrebidAdapterError('external_artifact_incompatible'); + if (bids !== response) throw new PrebidAdapterError('external_artifact_incompatible'); let matches = 0; - for (const bid of bids) { + 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 @@ -792,12 +829,12 @@ export function createBrowserPrebidAdapter( throw new PrebidAdapterError('external_artifact_incompatible'); } const requestIdentity = `${prepared.adUnitCode}\u0000${prepared.bid.requestId}`; - if ( - !context.requests.some( - (request) => - request.adUnitCode === prepared.adUnitCode && request.requestId === prepared.bid.requestId - ) - ) { + const request = context.requests.find( + (candidateRequest) => + candidateRequest.adUnitCode === prepared.adUnitCode && + candidateRequest.requestId === prepared.bid.requestId + ); + if (!request) { return 'not_admitted'; } if ( @@ -811,6 +848,7 @@ export function createBrowserPrebidAdapter( const isCurrent = (): boolean => !disposed && sameBinding(context.binding); const before = responseCount( context.binding, + prepared.auctionId, prepared.adUnitCode, prepared.bid.adId, prepared.bid.requestId, @@ -827,6 +865,7 @@ export function createBrowserPrebidAdapter( 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 @@ -839,10 +878,16 @@ export function createBrowserPrebidAdapter( 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) { @@ -858,6 +903,7 @@ export function createBrowserPrebidAdapter( try { after = responseCount( context.binding, + prepared.auctionId, prepared.adUnitCode, prepared.bid.adId, prepared.bid.requestId, @@ -970,7 +1016,7 @@ export function createBrowserPrebidAdapter( const context: ActiveTrustedServerAdmission = { addBidResponse: rawAddBidResponse as (...arguments_: unknown[]) => unknown, binding, - requests: request.bids, + requests: request.requests, admittedIds: new Set(), admittedRequests: new Set(), attemptedRequests: new Set(), diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index 00cfc2982..6cbb7b1d3 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -4,6 +4,12 @@ import { createBrowserPrebidAdapter, type PrebidEventFacade } from '../../src/ad 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); @@ -41,7 +47,7 @@ function createReadyPrebid( const listeners = new Map void>>(); const pbjs = { addAdUnits: vi.fn(), - getBidResponsesForAdUnitCode: vi.fn<() => { bids: object[] }>(() => ({ bids: [] })), + 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); @@ -1552,9 +1558,9 @@ describe('version-pinned Trusted Server bid admission', () => { function admissionFixture() { const ready = createReadyPrebid(); const stored: object[] = []; - ready.pbjs.getBidResponsesForAdUnitCode.mockImplementation((adUnitCode?: string) => ({ - bids: stored.filter((bid) => (bid as { adUnitCode?: unknown }).adUnitCode === adUnitCode), - })); + 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[] = []; @@ -1587,7 +1593,16 @@ describe('version-pinned Trusted Server bid admission', () => { bidder?.callBids( { auctionId: 'auction-one', - bids: [{ adUnitCode: 'slot-one', bidId: 'request-one' }], + bids: [ + { + adUnitCode: 'slot-one', + adUnitId: 'ad-unit-one', + auctionId: 'auction-one', + bidId: 'request-one', + src: 'client', + transactionId: 'transaction-one', + }, + ], }, admit, done @@ -1630,8 +1645,16 @@ describe('version-pinned Trusted Server bid admission', () => { expect(fixture.boundary.admitTrustedBid(prepared)).toBe('admitted'); expect(fixture.admit).toHaveBeenCalledTimes(1); const admitted = fixture.admit.mock.calls[0]?.[1]; - expect(admitted).toEqual(prepared.bid); + 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 @@ -1652,6 +1675,19 @@ describe('version-pinned Trusted Server bid admission', () => { 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; @@ -1668,19 +1704,19 @@ describe('version-pinned Trusted Server bid admission', () => { expect(fixture.stored).toEqual([]); }); - it('matches response state and events by exact request and ad-unit identity', async () => { + 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, - requestId: 'other-request', + auctionId: 'other-auction', adUnitCode: prepared.adUnitCode, }); fixture.admit.mockImplementation((adUnitCode, bid) => { fixture.emitBidResponse({ ...bid, - requestId: 'other-request', + auctionId: 'other-auction', adUnitCode, }); const published = { ...bid, adUnitCode }; 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 56c52349d..459d72db6 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 @@ -203,6 +203,96 @@ describe('external bundle + served shim evaluated together', () => { 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); + it('populates the public API, installs the shim exactly once, and routes an /auction request', async () => { const dom = new JSDOM('', { url: 'https://pub.example.com/article', From 5da081a337f25a40506bd38d1b0aee4122cd4b18 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:09:38 -0700 Subject: [PATCH 102/194] Fix Prebid contract test command --- .../plans/2026-08-04-aps-tsjs-resilience-implementation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index b7a1a1675..f83552b8a 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -2088,7 +2088,7 @@ collapse those checkpoints or carry unverified behavior between them. test/adapters/googletag.test.ts \ test/integrations/gpt/ad_init.test.ts npm --prefix crates/trusted-server-js/lib run build:prebid-external - node --test \ + npm --prefix crates/trusted-server-js/lib test -- --run \ crates/trusted-server-js/lib/test/build-prebid-external.test.mjs \ crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs ``` From 936bf5ccc0d7a240bc5a8589dd2784c8d6f0f523 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:10:10 -0700 Subject: [PATCH 103/194] Pin the Prebid response query contract --- crates/trusted-server-js/lib/test/adapters/prebid.test.ts | 1 + crates/trusted-server-js/lib/test/build-prebid-external.test.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index 6cbb7b1d3..10295f642 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -611,6 +611,7 @@ describe('browser Prebid adapter readiness', () => { it('requires every real API method and contains hostile target and member getters', async () => { for (const method of [ 'addAdUnits', + 'getBidResponsesForAdUnitCode', 'getHighestCpmBids', 'offEvent', 'onEvent', 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 b6afbb0b2..979928a0f 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 @@ -100,6 +100,7 @@ describe('build-prebid-external metadata', () => { 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_prebid_bundle'); From ec6eb5b00c4c8449cffea2999fc67c1dd9cc6dd6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:12:13 -0700 Subject: [PATCH 104/194] Activate Prebid listeners transactionally --- .../lib/src/integrations/prebid/module.ts | 24 ++++- .../test/integrations/prebid/module.test.ts | 96 +++++++++++++++++-- 2 files changed, 111 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index b5672d18f..6f28fc04c 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -42,6 +42,7 @@ const objectGetPrototypeOfIntrinsic = Object.getPrototypeOf; const objectIsFrozenIntrinsic = Object.isFrozen; interface PrebidIntegrationRuntime { + readonly activate: () => () => void; readonly start: (config: unknown) => void; } @@ -110,12 +111,22 @@ function readPrebidRuntime( candidate === null || arrayIsArrayIntrinsic(candidate) || !objectIsFrozenIntrinsic(candidate) || - Reflect.ownKeys(candidate).length !== 1 + Reflect.ownKeys(candidate).length !== 2 ) { return undefined; } + const activate = objectGetOwnPropertyDescriptorIntrinsic(candidate, 'activate'); const start = objectGetOwnPropertyDescriptorIntrinsic(candidate, 'start'); - if (!start || !('value' in start) || typeof start.value !== 'function') return undefined; + if ( + !activate || + !('value' in activate) || + typeof activate.value !== 'function' || + !start || + !('value' in start) || + typeof start.value !== 'function' + ) { + return undefined; + } return candidate as PrebidIntegrationRuntime; } catch { return undefined; @@ -133,7 +144,14 @@ export function createPrebidIntegrationRegistration(release: string): Integratio if (!runtime) throw new TypeError('Prebid integration runtime is unavailable'); return Object.freeze({ - activate: ({ afterCommit }: IntegrationActivationContext) => { + activate: ({ afterCommit, onDispose }: IntegrationActivationContext) => { + const runtimeRelease: { value?: () => void } = {}; + onDispose(() => runtimeRelease.value?.()); + const release = runtime.activate(); + if (typeof release !== 'function') { + throw new TypeError('Prebid integration activation disposer is unavailable'); + } + runtimeRelease.value = release; afterCommit(() => runtime.start(config)); }, }); 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 index 883aec893..2ea424d0c 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -48,13 +48,18 @@ function callbacks(order: string[]): IntegrationInstallCallbacks { } describe('transactional Prebid integration module', () => { - it('prepares inertly and starts the external boundary only after commit', async () => { + 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; @@ -67,7 +72,7 @@ describe('transactional Prebid integration module', () => { now: () => 0, getBindings: () => ({ config, - interfaces: Object.freeze({ prebid: Object.freeze({ start }) }), + interfaces: Object.freeze({ prebid: Object.freeze({ activate, start }) }), }), }); registry.register(createPrebidIntegrationRegistration(RELEASE_ID)); @@ -87,9 +92,84 @@ describe('transactional Prebid integration module', () => { const result = await installing; expect(result).toMatchObject({ state: 'kernel' }); - expect(order).toEqual(['gate:prepare', 'core', 'gate:activate', 'publish', 'start', 'drain']); + 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(); + 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(createPrebidIntegrationRegistration(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(createPrebidIntegrationRegistration(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 () => { @@ -131,7 +211,9 @@ describe('transactional Prebid integration module', () => { now: () => 0, getBindings: () => ({ config, - interfaces: Object.freeze({ prebid: Object.freeze({ start }) }), + interfaces: Object.freeze({ + prebid: Object.freeze({ activate: () => vi.fn(), start }), + }), }), }); registry.register(createPrebidIntegrationRegistration(RELEASE_ID)); @@ -157,7 +239,9 @@ describe('transactional Prebid integration module', () => { onRuntimeFailure: (failure) => runtimeFailures.push(failure), getBindings: () => ({ config: Object.freeze({}), - interfaces: Object.freeze({ prebid: Object.freeze({ start }) }), + interfaces: Object.freeze({ + prebid: Object.freeze({ activate: () => vi.fn(), start }), + }), }), }); registry.register(createPrebidIntegrationRegistration(RELEASE_ID)); From 494e183f63129903cc9dea023cc59b706e493a9a Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:14:12 -0700 Subject: [PATCH 105/194] Release Prebid bidder registrations explicitly --- .../lib/src/adapters/prebid.ts | 9 ++++---- .../lib/test/adapters/prebid.test.ts | 21 +++++++++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index 123022701..26b7dfe1c 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -129,7 +129,7 @@ export interface PrebidFacade { registerBidAdapter(adapter: unknown, bidderCode: string, spec?: object): unknown; registerTrustedServerBidder( listener: (auction: Readonly) => void - ): unknown; + ): () => void; renderAd(targetDocument: object, adId: string): unknown; requestBids(options: object): unknown; subscribe( @@ -957,7 +957,7 @@ export function createBrowserPrebidAdapter( listener: (auction: Readonly) => void, registerOperationEffect: (disposeEffect: () => void) => () => void, isOperationCurrent: () => boolean - ): unknown => { + ): (() => void) => { if (typeof listener !== 'function') { throw new TypeError('Trusted Server bidder listener must be a function'); } @@ -1043,12 +1043,13 @@ export function createBrowserPrebidAdapter( }); const bidderFactory = (): Readonly => bidder; try { - return callBound( + callBound( binding, 'registerBidAdapter', [bidderFactory, 'trustedServer'], isOperationCurrent ); + return release; } catch (error) { release(); throw error; @@ -1076,7 +1077,7 @@ export function createBrowserPrebidAdapter( ), registerTrustedServerBidder: ( listener: (auction: Readonly) => void - ): unknown => + ): (() => void) => registerTrustedServerBidder(binding, listener, registerOperationEffect, isOperationCurrent), renderAd: (targetDocument: object, adId: string): unknown => callBound(binding, 'renderAd', [targetDocument, adId], isOperationCurrent), diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index 10295f642..326e77051 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -1567,7 +1567,7 @@ describe('version-pinned Trusted Server bid admission', () => { const auctions: unknown[] = []; const operation = adapter.run((facade) => { const boundary = facade as unknown as { - registerTrustedServerBidder(listener: (auction: unknown) => void): unknown; + registerTrustedServerBidder(listener: (auction: unknown) => void): () => void; }; return boundary.registerTrustedServerBidder((auction) => auctions.push(auction)); }); @@ -1627,7 +1627,7 @@ describe('version-pinned Trusted Server bid admission', () => { it('captures one exact auction callback and admits a mutable copy atomically', async () => { const fixture = admissionFixture(); - await expect(fixture.operation.result).resolves.toBeUndefined(); + await expect(fixture.operation.result).resolves.toBeTypeOf('function'); expect(fixture.auctions).toHaveLength(1); const auction = fixture.auctions[0] as { @@ -1741,6 +1741,23 @@ describe('version-pinned Trusted Server bid admission', () => { 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; From ce459b75754bb887106bf41c38f5084ffd346ed5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:14:12 -0700 Subject: [PATCH 106/194] Bridge Prebid startup into runtime ownership --- .../lib/src/integrations/prebid/startup.ts | 45 ++++++++++ .../test/integrations/prebid/startup.test.ts | 83 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 crates/trusted-server-js/lib/src/integrations/prebid/startup.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts b/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts new file mode 100644 index 000000000..8173b2589 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts @@ -0,0 +1,45 @@ +import type { + PrebidAdapter, + PrebidEventFacade, + PrebidTrustedServerAuctionV1, +} from '../../adapters/prebid'; + +export interface PrebidStartup { + readonly activate: () => () => void; + readonly start: (config: unknown) => void; +} + +export interface PrebidStartupOptions { + readonly dispose: () => void; + readonly onAuction: (auction: Readonly) => void; + readonly onAuctionEnd: (event: unknown, prebid: Readonly) => void; + readonly prebid: Pick; + readonly start?: (config: unknown) => void; +} + +/** Join the version-pinned Prebid callbacks to runtime-owned publication and selection state. */ +export function createPrebidStartup(options: PrebidStartupOptions): PrebidStartup { + return Object.freeze({ + activate: (): (() => void) => { + const operation = options.prebid.run((prebid) => { + prebid.subscribe('auctionEnd', options.onAuctionEnd); + prebid.registerTrustedServerBidder(options.onAuction); + }); + void operation.result.catch(() => undefined); + let active = true; + return (): void => { + if (!active) return; + active = false; + try { + operation.dispose(); + } finally { + options.dispose(); + } + }; + }, + start: (config: unknown): void => { + options.start?.(config); + options.prebid.notifyReady(); + }, + }); +} 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..c8a0dbba9 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + PrebidAdapter, + PrebidEventFacade, + PrebidFacade, + PrebidTrustedServerAuctionV1, +} from '../../../src/adapters/prebid'; +import { createPrebidStartup } from '../../../src/integrations/prebid/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 eventFacade = Object.freeze({ highestBids: vi.fn(() => Object.freeze([])) }); + const facade = Object.freeze({ + registerTrustedServerBidder: vi.fn( + (listener: (auction: Readonly) => void) => { + bidderListener = listener; + } + ), + subscribe: vi.fn( + ( + eventType: string, + listener: (event: unknown, prebid: Readonly) => void + ) => { + expect(eventType).toBe('auctionEnd'); + auctionEndListener = 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: 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(facade.registerTrustedServerBidder).toHaveBeenCalledTimes(1); + expect(facade.subscribe).toHaveBeenCalledTimes(1); + const auction = Object.freeze({ + auctionId: 'auction-one', + bids: Object.freeze([]), + complete: vi.fn(), + }); + bidderListener?.(auction); + expect(onAuction).toHaveBeenCalledExactlyOnceWith(auction); + 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); + expect(start).toHaveBeenCalledExactlyOnceWith(config); + expect(notifyReady).toHaveBeenCalledTimes(1); + + release(); + release(); + expect(operationDispose).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + }); +}); From 6c182bc68dedab570963e4872c249521ef497d23 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:56:27 -0700 Subject: [PATCH 107/194] Arm Prebid selection before bidder startup --- .../lib/src/integrations/prebid/startup.ts | 75 ++++++++++++++++--- .../test/integrations/prebid/startup.test.ts | 71 +++++++++++++++--- 2 files changed, 125 insertions(+), 21 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts b/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts index 8173b2589..be5e8fa26 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts @@ -17,29 +17,80 @@ export interface PrebidStartupOptions { readonly start?: (config: unknown) => void; } -/** Join the version-pinned Prebid callbacks to runtime-owned publication and selection state. */ +/** Join the private bidder and early winner observer to one reversible runtime owner. */ export function createPrebidStartup(options: PrebidStartupOptions): PrebidStartup { + let activated = false; + let released = false; + let started = false; + let activationOperation: ReturnType | undefined; + let activationEffects: (() => void) | undefined; + let bidderOperation: ReturnType | undefined; + let bidderEffects: (() => void) | undefined; + + const retainEffects = ( + result: Promise, + publish: (release: () => void) => void + ): void => { + void result.then( + (candidate) => { + if (typeof candidate !== 'function') return; + if (released) candidate(); + else publish(candidate as () => void); + }, + () => undefined + ); + }; + + const disposeOwnedOperation = ( + operation: ReturnType | undefined, + releaseEffects: (() => void) | undefined + ): void => { + try { + operation?.dispose(); + } finally { + releaseEffects?.(); + } + }; + return Object.freeze({ activate: (): (() => void) => { - const operation = options.prebid.run((prebid) => { - prebid.subscribe('auctionEnd', options.onAuctionEnd); - prebid.registerTrustedServerBidder(options.onAuction); + if (activated || released) throw new Error('Prebid startup is already activated'); + activated = true; + activationOperation = options.prebid.run((prebid) => { + const releaseAuctionEnd = prebid.subscribe('auctionEnd', options.onAuctionEnd); + return releaseAuctionEnd; + }); + retainEffects(activationOperation.result, (release) => { + activationEffects = release; }); - void operation.result.catch(() => undefined); - let active = true; return (): void => { - if (!active) return; - active = false; + if (released) return; + released = true; try { - operation.dispose(); + disposeOwnedOperation(bidderOperation, bidderEffects); } finally { - options.dispose(); + try { + disposeOwnedOperation(activationOperation, activationEffects); + } finally { + options.dispose(); + } } }; }, start: (config: unknown): void => { - options.start?.(config); - options.prebid.notifyReady(); + if (!activated || released || started) throw new Error('Prebid startup is unavailable'); + started = true; + bidderOperation = options.prebid.run((prebid) => + prebid.registerTrustedServerBidder(options.onAuction) + ); + retainEffects(bidderOperation.result, (release) => { + bidderEffects = release; + }); + try { + options.start?.(config); + } finally { + options.prebid.notifyReady(); + } }, }); } 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 index c8a0dbba9..0431113ea 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts @@ -14,11 +14,19 @@ describe('Prebid startup bridge', () => { 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( @@ -27,8 +35,12 @@ describe('Prebid startup bridge', () => { listener: (event: unknown, prebid: Readonly) => void ) => { expect(eventType).toBe('auctionEnd'); + order.push('subscribe-auction-end'); auctionEndListener = listener; - return vi.fn(); + return () => { + order.push('release-auction-end'); + releaseAuctionEnd(); + }; } ), }) as unknown as Readonly; @@ -57,27 +69,68 @@ describe('Prebid startup bridge', () => { await Promise.resolve(); expect(run).toHaveBeenCalledTimes(1); - expect(facade.registerTrustedServerBidder).toHaveBeenCalledTimes(1); + expect(order).toEqual(['subscribe-auction-end']); + expect(facade.registerTrustedServerBidder).not.toHaveBeenCalled(); expect(facade.subscribe).toHaveBeenCalledTimes(1); - const auction = Object.freeze({ - auctionId: 'auction-one', - bids: Object.freeze([]), - complete: vi.fn(), - }); - bidderListener?.(auction); - expect(onAuction).toHaveBeenCalledExactlyOnceWith(auction); 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); }); }); From 9153a68acd9376619f5242abd0f7ddb0079ae186 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:56:27 -0700 Subject: [PATCH 108/194] Wire Prebid publication into browser composition --- .../lib/src/composition/browser.ts | 138 ++++++++++- .../lib/test/composition/browser.test.ts | 222 ++++++++++++++++++ 2 files changed, 347 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 3b4539f80..8975b00d1 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -16,9 +16,11 @@ import { createNoopPrebidAdapter, type PrebidAdapter, type PrebidGlobalTarget, + type PrebidTrustedServerAuctionV1, } from '../adapters/prebid'; import { parseCacheFetchPolicyV1 } from '../core/config'; import { parseTrustedServerAuctionResponseV1 } from '../core/auction'; +import type { BrowserAuctionProjectionV1 } from '../core/types'; import { parseBidRenderSourceV1, parseBrowserAuctionProjectionV1, @@ -42,8 +44,18 @@ import { type GptWinnerPublicationResult, } from '../integrations/gpt/module'; import { createGptStartup } from '../integrations/gpt/startup'; +import { + createPrebidSelectionCoordinator, + publishPrebidBid, + type PrebidSelectionCoordinator, +} from '../integrations/prebid/module'; +import { createPrebidStartup } from '../integrations/prebid/startup'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; -import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; +import type { + NavigationIdentityIssuerFactory, + RenderAttemptScope, + RuntimeSession, +} from '../kernel/sessions'; import { createRuntimeSession } from '../kernel/sessions'; import type { CoreActivationContext } from '../kernel/integration_registry'; import { createRuntime, type Runtime, type RuntimeOptions } from '../kernel/runtime'; @@ -161,6 +173,7 @@ export interface TestBrowserRuntimeCompositionOptions extends BrowserComposition readonly admittedProgrammaticSlotsForTest?: readonly string[]; readonly gptStartupForTest?: (config: unknown) => void; readonly prebidStartupForTest?: (config: unknown) => void; + readonly pucSchedulerForTest?: PucBridgeOptions['scheduler']; } interface AcceptedBrowserBoot { @@ -172,6 +185,7 @@ interface AcceptedBrowserBoot { } interface PreparedBrowserServices { + readonly createAttempt: (owner: RenderAttemptScope) => ReturnType; readonly publisherOrigin: string; readonly rendererUrl: string; readonly resolveCacheAdm: NonNullable; @@ -265,9 +279,77 @@ export function createTestBrowserRuntimeComposition( }, start: startGpt, }); - const startPrebid = compositionOptions.prebidStartupForTest ?? (() => undefined); - const prebidRuntime = Object.freeze({ start: startPrebid }); let runtimeSession: RuntimeSession | undefined; + let prebidCoordinator: PrebidSelectionCoordinator | undefined; + const startPrebid = compositionOptions.prebidStartupForTest ?? (() => undefined); + 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; + 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, + }); + } + } 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, + start: startPrebid, + }); const getBindings: NonNullable = (id) => { const provided = providedBindings?.(id); let config: unknown; @@ -620,18 +702,19 @@ export function createTestBrowserRuntimeComposition( } }; const fetchAuction = compositionOptions.auctionFetcherForTest ?? globalThis.fetch; + const createOwnedAttempt = (owner: RenderAttemptScope) => + createRenderAttempt({ + artifacts, + owner, + prepareRenderSource: (candidate) => { + const source = parseBidRenderSourceV1(candidate, cachePolicy); + return source ? Object.freeze(source) : undefined; + }, + reservations: reservationService, + }); const batchCoordinator = createAuctionBatchService({ ...(cachePolicy ? { cachePolicy } : {}), - createAttempt: (owner) => - createRenderAttempt({ - artifacts, - owner, - prepareRenderSource: (candidate) => { - const source = parseBidRenderSourceV1(candidate, cachePolicy); - return source ? Object.freeze(source) : undefined; - }, - reservations: reservationService, - }), + createAttempt: createOwnedAttempt, fetcher: (input, init) => { if (typeof fetchAuction !== 'function') return Promise.reject(new Error('unavailable')); return fetchAuction(input, init); @@ -663,6 +746,7 @@ export function createTestBrowserRuntimeComposition( targeting: targetingService, }); preparedBrowserServices = Object.freeze({ + createAttempt: createOwnedAttempt, publisherOrigin, rendererUrl, resolveCacheAdm, @@ -732,6 +816,9 @@ export function createTestBrowserRuntimeComposition( 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, @@ -740,6 +827,31 @@ export function createTestBrowserRuntimeComposition( }); context.onDispose(() => pucBridge.dispose()); browserServices = Object.freeze({ ...prepared.services, pucBridge }); + 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(); compositionOptions.coreActivations.correctnessGptListeners( context, diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 7ab2c9811..ee57a3e28 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -8,6 +8,7 @@ import { type GoogletagFacade, } from '../../src/adapters/googletag'; import { + createBrowserMessagingAdapter, createNoopMessagingAdapter, type CaptureMessageListener, type MessagingAdapter, @@ -16,6 +17,10 @@ import { createNoopPrebidAdapter, type PrebidAdapter, type PrebidBindingStatus, + type PrebidEventFacade, + type PrebidFacade, + type PrebidTrustedServerAuctionV1, + type PreparedTrustedBidV1, } from '../../src/adapters/prebid'; import { createBrowserComposition, @@ -116,6 +121,79 @@ function fakePrebidAdapter( return Object.freeze({ ...createNoopPrebidAdapter(), bindingStatus }); } +function synchronousPrebidAdapter() { + let auctionListener: ((auction: Readonly) => void) | undefined; + let auctionEndListener: + ((event: unknown, prebid: Readonly) => void) | undefined; + let admitted: Readonly | undefined; + const admitTrustedBid = vi.fn((prepared: Readonly) => { + admitted = prepared; + return 'admitted' as const; + }); + 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: vi.fn(), + 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 }) + ); + }, + }; +} + function fakeMessagingAdapter( installCaptureListener: MessagingAdapter['installCaptureListener'] = () => vi.fn() ): MessagingAdapter { @@ -674,6 +752,150 @@ describe('browser composition', () => { expect(isGuardInstalled()).toBe(false); }); + 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, + }), + ]), + }), + bids: Object.freeze([bid]), + }); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [{ id: 'prebid', required: true }], + }, + 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(createPrebidIntegrationRegistration(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(); + } + }); + 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' }); From e1aa8b7f8b197bcc51fe063a73d39e3bfc6022e9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:05:29 -0700 Subject: [PATCH 109/194] Establish creative integration ownership --- .../lib/src/integrations/creative/module.ts | 113 +++++++ .../lib/src/integrations/creative/startup.ts | 120 ++++++++ .../test/integrations/creative/module.test.ts | 276 ++++++++++++++++++ .../integrations/creative/startup.test.ts | 172 +++++++++++ 4 files changed, 681 insertions(+) create mode 100644 crates/trusted-server-js/lib/src/integrations/creative/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/creative/startup.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/creative/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts 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..f797267b9 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/creative/module.ts @@ -0,0 +1,113 @@ +import type { CreativeBootV1 } from '../../core/types'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from '../../kernel/integration_registry'; + +export const CREATIVE_INTEGRATION_ID = 'creative' as const; + +interface CreativeIntegrationRuntime { + readonly activate: (config: Readonly) => () => void; + readonly start: (config: Readonly) => void; +} + +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' + ? (candidate as Readonly) + : undefined; + } catch { + return undefined; + } +} + +function readCreativeRuntime( + interfaces: Readonly> +): CreativeIntegrationRuntime | undefined { + try { + const descriptor = Object.getOwnPropertyDescriptor(interfaces, CREATIVE_INTEGRATION_ID); + if (!descriptor || !('value' in descriptor)) return undefined; + const candidate = descriptor.value; + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + !Object.isFrozen(candidate) || + Reflect.ownKeys(candidate).length !== 2 + ) { + return undefined; + } + const activate = Object.getOwnPropertyDescriptor(candidate, 'activate'); + const start = Object.getOwnPropertyDescriptor(candidate, 'start'); + if ( + !activate || + !('value' in activate) || + typeof activate.value !== 'function' || + !start || + !('value' in start) || + typeof start.value !== 'function' + ) { + return undefined; + } + return candidate as CreativeIntegrationRuntime; + } catch { + return undefined; + } +} + +/** Build the inert, release-bound creative module for the coordinated runtime. */ +export function createCreativeIntegrationRegistration(release: string): IntegrationRegistration { + return Object.freeze({ + id: CREATIVE_INTEGRATION_ID, + release, + prepare: async ({ config, interfaces }: IntegrationPrepareContext) => { + const creative = readCreativeBoot(config); + if (!creative) throw new TypeError('Creative boot configuration is invalid'); + const runtime = readCreativeRuntime(interfaces); + if (!runtime) throw new TypeError('Creative integration runtime is unavailable'); + if (!creative.enabled || (!creative.clickGuard && !creative.renderGuard)) { + return Object.freeze({ activate: () => undefined }); + } + + 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..cf5167612 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/creative/startup.ts @@ -0,0 +1,120 @@ +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) { + 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/test/integrations/creative/module.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts new file mode 100644 index 000000000..22393ce85 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it, vi } from 'vitest'; + +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, + integrations: ids.map((id) => ({ id, required: true })), + }; +} + +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({ id, release: RELEASE_ID, prepare }); +} + +describe('transactional creative integration module', () => { + 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 release = vi.fn(() => order.push('release')); + const activate = vi.fn((received: unknown) => { + order.push('creative:activate'); + expect(received).toBe(config); + return release; + }); + const start = vi.fn(() => order.push('creative:scan')); + 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']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ creative: Object.freeze({ activate, start }) }), + }), + }); + 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(activate).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + + finishPreparation?.(); + const result = await installing; + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'gate:prepare', + 'core', + 'creative:activate', + 'gate:activate', + 'publish', + 'creative:scan', + 'drain', + ]); + if (result.state === 'kernel') { + result.dispose(); + result.dispose(); + } + expect(release).toHaveBeenCalledTimes(1); + }); + + it.each([ + Object.freeze({ version: 1, enabled: false, clickGuard: true, renderGuard: true }), + Object.freeze({ version: 1, enabled: true, clickGuard: false, renderGuard: false }), + ])('performs no runtime work for an inactive creative boot %#', async (config) => { + const activate = vi.fn(); + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['creative']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['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: 'kernel' }); + expect(activate).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + }); + + it('unwinds creative activation before a later module failure', async () => { + const release = vi.fn(); + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['creative', 'broken']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['creative', 'broken']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }), + interfaces: Object.freeze({ + creative: Object.freeze({ activate: () => release, start }), + }), + }), + }); + 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(release).toHaveBeenCalledTimes(1); + expect(start).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 }], + ])('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']), + 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 the creative boundary', async () => { + const registry = createIntegrationRegistry({ + manifest: manifest(['creative']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['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('isolates a post-commit scan failure to the creative module', async () => { + const runtimeFailures: unknown[] = []; + const start = vi.fn(() => { + throw new Error('fictional creative scan failure'); + }); + const registry = createIntegrationRegistry({ + manifest: manifest(['creative']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['creative']), + startedAtMs: 0, + now: () => 0, + onRuntimeFailure: (failure) => runtimeFailures.push(failure), + getBindings: () => ({ + config: Object.freeze({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }), + interfaces: Object.freeze({ + creative: Object.freeze({ activate: () => vi.fn(), start }), + }), + }), + }); + registry.register(createCreativeIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'kernel', + runtimeFailures: [{ id: 'creative', phase: 'after_commit' }], + }); + expect(runtimeFailures).toEqual([{ id: 'creative', phase: 'after_commit' }]); + }); +}); 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..8a7bf7a30 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts @@ -0,0 +1,172 @@ +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('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'); + }); +}); From f71f480e9627ccc70941c1793a6d1b5e5f9ffa5c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:12:32 -0700 Subject: [PATCH 110/194] Own the creative guard lifecycle --- .../lib/src/integrations/creative/click.ts | 68 +- .../creative/dynamic_src_guard.ts | 613 +++++++++++------- .../lib/src/integrations/creative/iframe.ts | 5 +- .../lib/src/integrations/creative/image.ts | 5 +- .../lib/src/integrations/creative/index.ts | 28 +- .../lib/src/shared/scheduler.ts | 25 +- .../test/integrations/creative/click.test.ts | 40 +- .../lib/test/integrations/creative/helpers.ts | 12 +- .../test/integrations/creative/iframe.test.ts | 24 +- .../test/integrations/creative/image.test.ts | 30 +- .../integrations/creative/ownership.test.ts | 110 ++++ .../lib/test/shared/scheduler.test.ts | 14 + 12 files changed, 706 insertions(+), 268 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts diff --git a/crates/trusted-server-js/lib/src/integrations/creative/click.ts b/crates/trusted-server-js/lib/src/integrations/creative/click.ts index f350c4a65..a1e496705 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts @@ -5,6 +5,8 @@ import { delay, queueTask } from '../../shared/async'; import { hasOpaqueOrigin, TRUSTED_BASE_URL } from '../../shared/origin'; import { createMutationScheduler } from '../../shared/scheduler'; +import type { CreativeGuardHandle } from './startup'; + type AnchorLike = HTMLAnchorElement | HTMLAreaElement; type Canon = { base: string; params: Record }; type Diff = { add: Record; del: string[] }; @@ -347,9 +349,11 @@ async function rebuildIfNeeded(anchor: AnchorLike, tsClickStr: string): Promise< async function guardNavigation( anchor: AnchorLike, tsClickStr: string, - isMiddle: boolean + isMiddle: boolean, + isActive: () => boolean ): Promise { const finalUrl = await rebuildIfNeeded(anchor, tsClickStr); + if (!isActive()) return; if (finalUrl && finalUrl !== tsClickStr) { persistRebuiltClick(anchor, finalUrl); } @@ -357,7 +361,7 @@ async function guardNavigation( } // 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, isActive: () => boolean): void { const anchor = closestAnchor(ev.target); if (!anchor) return; @@ -367,7 +371,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, isActive).catch((err) => { + if (!isActive()) return; log.warn('tsjs-creative:click: failed to compute final URL', err); navigate(anchor, tsClickStr, isMiddle); }); @@ -377,14 +383,18 @@ 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(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) .then((finalUrl) => { + if (!isActive()) return; if (finalUrl && finalUrl !== tsClickStr) { persistRebuiltClick(anchor, finalUrl); } @@ -394,14 +404,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 +426,61 @@ 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'); + let active = true; + const isActive = (): boolean => active; const onClick = (ev: Event) => { - handleGuardedClick(ev, false); + if (!active) return; + handleGuardedClick(ev, false, isActive); }; const onAuxClick = (ev: MouseEvent) => { + if (!active) return; if (ev.button !== 1) return; - handleGuardedClick(ev, true); + handleGuardedClick(ev, true, 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(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 0e8d4cb84..386b0582c 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,301 +33,423 @@ 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: unknown; + + 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 && + factoryTarget[options.factoryName] === installedFactory + ) { + try { + if (factoryOriginal) { + Object.defineProperty(factoryTarget, options.factoryName, factoryOriginal); + } else { + Reflect.deleteProperty(factoryTarget, options.factoryName); + } + } catch (error) { + log.debug(`${options.logPrefix}: failed to restore ${options.factoryName}`, error); + } + } + restore(ctor.prototype, 'setAttributeNS', installedSetAttributeNS, originalSetAttributeNS); + restore(ctor.prototype, 'setAttribute', installedSetAttribute, originalSetAttribute); + restore(ctor.prototype, attr, installedSource, sourceDescriptor); }; - Object.defineProperty(WrappedFactory, 'length', { - value: factoryFn.length, - configurable: true, - }); - Object.defineProperty(WrappedFactory, 'name', { - value: options.factoryName, - configurable: true, - }); - WrappedFactory.prototype = factoryFn.prototype; - Object.setPrototypeOf(WrappedFactory, factoryFn); - - globalObj[options.factoryName] = WrappedFactory as unknown; - factoryPatched = true; - } - - return function install(): void { - if (proxyInstalled) return; - const ctor = options.elementConstructor; - if (typeof ctor !== 'function') return; - - log.info(`${options.logPrefix}: installing dynamic ${options.resourceName} proxy hooks`); - - const descriptor = Object.getOwnPropertyDescriptor(ctor.prototype, attr); - if (!descriptor || typeof descriptor.set !== 'function') { - log.debug(`${options.logPrefix}: ${ctor.name} proxy install skipped (no setter)`); - return; - } - - nativeSet = descriptor.set as typeof nativeSet; - nativeGet = - typeof descriptor.get === 'function' ? (descriptor.get as typeof nativeGet) : undefined; - nativeSetAttribute = ctor.prototype.setAttribute as typeof nativeSetAttribute; - nativeSetAttributeNS = - typeof ctor.prototype.setAttributeNS === 'function' - ? (ctor.prototype.setAttributeNS as typeof nativeSetAttributeNS) - : undefined; + const handle = Object.freeze({ dispose, scan }); - let prototypePatched = false; - if (descriptor.configurable !== false) { - try { + try { + log.info(`${options.logPrefix}: installing dynamic ${options.resourceName} proxy hooks`); + let prototypePatched = false; + if (sourceDescriptor.configurable !== false) { Object.defineProperty(ctor.prototype, attr, { configurable: true, - enumerable: descriptor.enumerable ?? true, + enumerable: sourceDescriptor.enumerable ?? true, get(this: E) { - log.info(`${options.logPrefix}: ${ctor.name} ${attr} get`); const pending = assignments.get(this); if (pending) return pending.raw; return nativeGet ? nativeGet.call(this) : ''; }, set(this: E, value: string) { + if (!active) { + apply(this, String(value ?? '')); + return; + } log.info(`${options.logPrefix}: ${ctor.name} ${attr} set`, value); proxyAssignment(this, String(value ?? '')); }, }); + installedSource = Object.getOwnPropertyDescriptor(ctor.prototype, attr); prototypePatched = true; - } catch (err) { - log.debug(`${options.logPrefix}: failed to patch prototype ${attr}`, err); - } - } else { - log.debug(`${options.logPrefix}: prototype ${attr} not configurable; using fallback`); - } - - ctor.prototype.setAttribute = function patchedSetAttribute( - this: E, - name: string, - value: string - ) { - log.debug(`${options.logPrefix}: ${ctor.name} setAttribute`, { name, value }); - if (typeof name === 'string' && name.toLowerCase() === attr) { - proxyAssignment(this, String(value ?? '')); - return; + } else { + log.debug(`${options.logPrefix}: prototype ${attr} not configurable; using fallback`); } - nativeSetAttribute.call(this, name, value); - }; - if (nativeSetAttributeNS) { - ctor.prototype.setAttributeNS = function patchedSetAttributeNS( + ctor.prototype.setAttribute = function patchedSetAttribute( this: E, - namespace: string | null, name: string, value: string ): void { - log.debug(`${options.logPrefix}: ${ctor.name} setAttributeNS`, { namespace, name, value }); - if (typeof name === 'string' && name.toLowerCase() === attr) { - proxyAssignment(this, String(value ?? '')); + if (!active || typeof name !== 'string' || name.toLowerCase() !== attr) { + nativeSetAttribute.call(this, name, value); return; } - nativeSetAttributeNS!.call(this, namespace, name, value); + log.debug(`${options.logPrefix}: ${ctor.name} setAttribute`, { name, value }); + proxyAssignment(this, String(value ?? '')); }; - } + installedSetAttribute = Object.getOwnPropertyDescriptor(ctor.prototype, 'setAttribute'); + + if (nativeSetAttributeNS) { + ctor.prototype.setAttributeNS = function patchedSetAttributeNS( + this: E, + namespace: string | null, + name: string, + value: string + ): void { + if (!active || typeof name !== 'string' || name.toLowerCase() !== attr) { + nativeSetAttributeNS.call(this, namespace, name, value); + return; + } + log.debug(`${options.logPrefix}: ${ctor.name} setAttributeNS`, { + namespace, + name, + value, + }); + proxyAssignment(this, String(value ?? '')); + }; + installedSetAttributeNS = Object.getOwnPropertyDescriptor(ctor.prototype, 'setAttributeNS'); + } - proxyInstalled = true; - log.info(`${options.logPrefix}: dynamic ${options.resourceName} proxy installed`); + if (!prototypePatched) { + if (targetDocument && nativeCreateElement) { + targetDocument + .querySelectorAll(options.selector) + .forEach((element) => ensureInstancePatched(element as E)); + targetDocument.createElement = function patchedCreateElement( + this: Document, + name: string, + creationOptions?: ElementCreationOptions + ): HTMLElement { + const element = nativeCreateElement.call(this, name, creationOptions); + if (active && typeof name === 'string' && name.toLowerCase() === tagName) { + ensureInstancePatched(element as unknown as E); + } + return element; + } as typeof targetDocument.createElement; + installedCreateElement = Object.getOwnPropertyDescriptor(targetDocument, 'createElement'); + } - if (!prototypePatched) { - log.info(`${options.logPrefix}: using instance-level proxy fallback`); - if (typeof document !== 'undefined') { - document.querySelectorAll(options.selector).forEach((el) => ensureInstancePatched(el as E)); + if (options.factoryName) { + const globalObject = globalThis as Record; + const factory = globalObject[options.factoryName]; + if (typeof factory === 'function') { + const factoryFunction = factory as FactoryFunction; + factoryTarget = globalObject; + factoryOriginal = Object.getOwnPropertyDescriptor(globalObject, options.factoryName); + const WrappedFactory = function (this: unknown, ...args: unknown[]) { + const instance = Reflect.construct( + factoryFunction, + args, + new.target ?? WrappedFactory + ) as E; + if (active) ensureInstancePatched(instance); + return instance; + }; + Object.defineProperty(WrappedFactory, 'length', { + value: factoryFunction.length, + configurable: true, + }); + Object.defineProperty(WrappedFactory, 'name', { + value: options.factoryName, + configurable: true, + }); + WrappedFactory.prototype = factoryFunction.prototype; + Object.setPrototypeOf(WrappedFactory, factoryFunction); + globalObject[options.factoryName] = WrappedFactory; + installedFactory = WrappedFactory; + } + } } - 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..e3589e496 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/index.ts @@ -6,6 +6,7 @@ import { creativeGlobal, resolveWindow } from '../../shared/globals'; import { installClickGuard } from './click'; import { installDynamicImageProxy } from './image'; import { installDynamicIframeProxy } from './iframe'; +import type { CreativeGuardHandle } from './startup'; export { installDynamicImageProxy } from './image'; export { installDynamicIframeProxy } from './iframe'; @@ -19,20 +20,41 @@ let currentConfig: Required = { ...DEFAULT_CONFIG }; let guardsInstallTriggered = false; let clickGuardInstalled = false; let renderGuardInstalled = false; +let clickGuardHandle: CreativeGuardHandle | undefined; +let imageGuardHandle: CreativeGuardHandle | undefined; +let iframeGuardHandle: CreativeGuardHandle | undefined; function applyConfig(): void { if (currentConfig.clickGuard && !clickGuardInstalled) { - installClickGuard(); + clickGuardHandle = installClickGuard(); clickGuardInstalled = true; } if (currentConfig.renderGuard && !renderGuardInstalled) { - installDynamicImageProxy(); - installDynamicIframeProxy(); + imageGuardHandle = installDynamicImageProxy(); + iframeGuardHandle = installDynamicIframeProxy(); renderGuardInstalled = true; } } +/** Release only the wrappers, listeners, observers, and queued work installed by this module. */ +export function disposeGuards(): void { + const handles = [iframeGuardHandle, imageGuardHandle, clickGuardHandle]; + iframeGuardHandle = undefined; + imageGuardHandle = undefined; + clickGuardHandle = undefined; + renderGuardInstalled = false; + clickGuardInstalled = false; + guardsInstallTriggered = false; + for (let index = 0; index < handles.length; index += 1) { + try { + handles[index]?.dispose(); + } catch { + // One hostile cleanup cannot retain another guard's owned state. + } + } +} + function mergeConfig(cfg: TsCreativeConfig): void { currentConfig = { clickGuard: cfg.clickGuard ?? currentConfig.clickGuard, diff --git a/crates/trusted-server-js/lib/src/shared/scheduler.ts b/crates/trusted-server-js/lib/src/shared/scheduler.ts index 32664f635..088eadfe5 100644 --- a/crates/trusted-server-js/lib/src/shared/scheduler.ts +++ b/crates/trusted-server-js/lib/src/shared/scheduler.ts @@ -1,15 +1,34 @@ // Mutation observer helper that batches callbacks onto the microtask queue. import { queueTask } from './async'; +export interface MutationScheduler { + (target: T): void; + readonly dispose: () => void; +} + // Coalesce repeated mutation callbacks on the same element into a single microtask run. -export function createMutationScheduler(perform: (target: T) => void) { +export function createMutationScheduler( + perform: (target: T) => void +): MutationScheduler { const queued = new WeakSet(); - return (target: T) => { + let active = true; + const schedule = ((target: T): void => { + if (!active) return; if (queued.has(target)) return; queued.add(target); queueTask(() => { queued.delete(target); + if (!active) return; perform(target); }); - }; + }) as MutationScheduler; + Object.defineProperty(schedule, 'dispose', { + configurable: false, + enumerable: true, + value: (): void => { + active = false; + }, + writable: false, + }); + return Object.freeze(schedule); } diff --git a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts index fae4eb407..beb9411b1 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, + disposeImportedCreativeModule, + importCreativeModule, +} 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; 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..1ce8a069c 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/helpers.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/helpers.ts @@ -19,7 +19,16 @@ export const PROXY_RESPONSE = import type { TsCreativeConfig } from '../../../src/shared/globals'; +let disposeLastImportedCreative: (() => void) | undefined; + +export function disposeImportedCreativeModule(): void { + const dispose = disposeLastImportedCreative; + disposeLastImportedCreative = undefined; + dispose?.(); +} + export async function importCreativeModule(config?: TsCreativeConfig): Promise { + disposeImportedCreativeModule(); const globalRef = globalThis as { __ts_creative_installed?: boolean; tsCreativeConfig?: TsCreativeConfig; @@ -28,7 +37,8 @@ export async function importCreativeModule(config?: TsCreativeConfig): Promise { const ORIGINAL_FETCH = global.fetch; beforeEach(() => { + disposeImportedCreativeModule(); vi.resetModules(); document.body.innerHTML = ''; }); afterEach(() => { + disposeImportedCreativeModule(); global.fetch = ORIGINAL_FETCH; }); @@ -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..525bb66ad 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 { disposeImportedCreativeModule, importCreativeModule, 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; }); @@ -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/ownership.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts new file mode 100644 index 000000000..1629c5a20 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts @@ -0,0 +1,110 @@ +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 + ); + }); + + 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/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(); + }); }); From 14572b296bdf49fd4c17649f41bd95ef1daa1fc4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:13:52 -0700 Subject: [PATCH 111/194] Allow clean creative guard reactivation --- .../creative/dynamic_src_guard.ts | 21 +++++-------------- .../integrations/creative/ownership.test.ts | 10 +++++++++ 2 files changed, 15 insertions(+), 16 deletions(-) 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 386b0582c..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 @@ -116,7 +116,7 @@ export function createDynamicSrcProxy( let installedCreateElement: PropertyDescriptor | undefined; let factoryTarget: Record | undefined; let factoryOriginal: PropertyDescriptor | undefined; - let installedFactory: unknown; + let installedFactory: PropertyDescriptor | undefined; const restore = ( target: object, @@ -268,24 +268,13 @@ export function createDynamicSrcProxy( if (targetDocument) { restore(targetDocument, 'createElement', installedCreateElement, originalCreateElement); } - if ( - factoryTarget && - options.factoryName && - factoryTarget[options.factoryName] === installedFactory - ) { - try { - if (factoryOriginal) { - Object.defineProperty(factoryTarget, options.factoryName, factoryOriginal); - } else { - Reflect.deleteProperty(factoryTarget, options.factoryName); - } - } catch (error) { - log.debug(`${options.logPrefix}: failed to restore ${options.factoryName}`, error); - } + 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; }; const handle = Object.freeze({ dispose, scan }); @@ -398,7 +387,7 @@ export function createDynamicSrcProxy( WrappedFactory.prototype = factoryFunction.prototype; Object.setPrototypeOf(WrappedFactory, factoryFunction); globalObject[options.factoryName] = WrappedFactory; - installedFactory = WrappedFactory; + installedFactory = Object.getOwnPropertyDescriptor(globalObject, options.factoryName); } } } 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 index 1629c5a20..6e1b8bcb5 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts @@ -81,6 +81,16 @@ describe('creative guard ownership', () => { 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 () => { From 26025df8e4b3405a6cc3ffec4b58542e0cd9ba8b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:14:39 -0700 Subject: [PATCH 112/194] Harden GPT publisher handoff ownership --- .../lib/src/adapters/googletag.ts | 53 +++- .../lib/src/composition/browser.ts | 1 + .../lib/src/integrations/gpt/startup.ts | 6 +- .../lib/src/services/slots.ts | 207 +++++++++++---- .../lib/src/services/targeting.ts | 34 ++- .../lib/test/integrations/gpt/module.test.ts | 4 +- .../lib/test/integrations/gpt/startup.test.ts | 44 +++- .../lib/test/services/slots.test.ts | 236 +++++++++++++++++- .../lib/test/services/targeting.test.ts | 98 ++++++++ 9 files changed, 606 insertions(+), 77 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index 0889fb019..d4c00efd6 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -83,6 +83,12 @@ 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; +} + /** One publisher-originated GPT call observed outside Trusted Server operations. */ export interface GoogletagPublisherCallObserver { readonly defineSlot?: ( @@ -131,7 +137,10 @@ export interface GoogletagFacade { clearTargeting(slot: object, key?: string): unknown; display(slot: string | object): unknown; getTargeting(slot: object, key: string): readonly string[]; - observeTargeting(slot: object, observer: GoogletagTargetingObserver): () => void; + observeTargeting( + slot: object, + observer: GoogletagTargetingObserver + ): GoogletagTargetingObservation; refresh(slots?: readonly object[], options?: Readonly<{ changeCorrelator: boolean }>): unknown; serviceState(): Readonly<{ apiReady: boolean; @@ -227,6 +236,7 @@ interface SharedInitialLoadTracker { } interface TargetingObservation { + readonly isCurrent: () => boolean; readonly observers: Set; readonly restore: () => void; } @@ -427,7 +437,7 @@ function createFacade( slot: object, key: 'clearTargeting' | 'setTargeting', observer: GoogletagTargetingObserver - ): (() => void) | undefined => { + ): Readonly<{ isCurrent: () => boolean; restore: () => void }> | undefined => { if (!isOperationCurrent()) return undefined; const original = member(slot, key); let descriptor: PropertyDescriptor | undefined; @@ -455,6 +465,15 @@ function createFacade( // 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 ( @@ -479,7 +498,7 @@ function createFacade( restore(); return undefined; } - return restore; + return Object.freeze({ isCurrent: wrapperIsCurrent, restore }); } catch { restore(); return undefined; @@ -506,7 +525,10 @@ function createFacade( if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); return Object.freeze([...targeting]); }, - observeTargeting: (slot: object, observer: GoogletagTargetingObserver): (() => void) => { + observeTargeting: ( + slot: object, + observer: GoogletagTargetingObserver + ): GoogletagTargetingObservation => { if ( typeof observer !== 'object' || observer === null || @@ -535,19 +557,20 @@ function createFacade( if (!restoreSet) throw new GoogletagAdapterError('external_artifact_incompatible'); const restoreClear = replaceObservedMethod(slot, 'clearTargeting', dispatcher); if (!restoreClear) { - restoreSet(); + 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(); + restoreClear.restore(); } finally { - restoreSet(); + restoreSet.restore(); } }, }; @@ -570,7 +593,7 @@ function createFacade( throw error; } let active = true; - return registerEffect(() => { + const releaseEffect = registerEffect(() => { if (!active) return; active = false; deleteSetValue(observation!.observers, observer); @@ -581,6 +604,20 @@ function createFacade( 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[], diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 8975b00d1..de40a8133 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -578,6 +578,7 @@ export function createTestBrowserRuntimeComposition( }, googletag: composition.adapters.googletag, ...(reconciliation ? { reconciliation } : {}), + warnPublisherHandoffMismatch: (message, details) => log.warn(message, details), }); const targetingService = createTargetingService(); const reservationService = createReservationService({ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts b/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts index d0f92278b..0a63d88ad 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts @@ -14,6 +14,7 @@ type GptPublisherSlotBoundary = Pick< | 'preparePublisherDisplay' | 'preparePublisherRefresh' | 'recordPublisherDestruction' + | 'start' >; export interface GptStartup { @@ -48,6 +49,9 @@ export function createGptStartup(options: GptStartupOptions): GptStartup { }); return options.googletag.observePublisherCalls(observer); }, - start: (config: unknown): void => options.start?.(config), + start: (config: unknown): void => { + options.slots().start(); + options.start?.(config); + }, }); } diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index c0ee6b796..e9a348eff 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -10,6 +10,7 @@ import type { GoogletagReplacementResult, } from '../adapters/googletag'; import { + GoogletagAdapterError, GoogletagReplacementCandidateCollisionError, GoogletagReplacementError, } from '../adapters/googletag'; @@ -84,6 +85,8 @@ export type GptSlotAdoptionResult = Readonly< export type SlotRequestFailure = | 'cycle_unattributable' + | 'external_queue_full' + | 'external_ready_timeout' | 'gpt_completion_timeout' | 'gpt_request_failed' | 'gpt_request_timeout' @@ -130,7 +133,7 @@ export interface SlotServiceInventory { /** Runtime-owned slot registry and physical-cycle boundary. */ export interface SlotService { - readonly activate: () => GoogletagOperation; + readonly activate: () => void; readonly adoptGptSlot: ( navigationGeneration: object, registeredSlotId: string, @@ -170,6 +173,7 @@ export interface SlotService { readonly request: (input: SlotRequestInput) => SlotRequestHandle; readonly requestBatch: (inputs: readonly SlotBatchRequestInput[]) => readonly SlotRequestHandle[]; readonly snapshotRegisteredSlots: (owner: NavigationSession) => readonly SlotRecord[] | undefined; + readonly start: () => GoogletagOperation; readonly resolveAdUnitCode: (adUnitCode: string) => SlotRecord | undefined; readonly resolveDomAlias: (alias: string) => SlotRecord | undefined; readonly resolveRegisteredSlot: (registeredSlotId: string) => SlotRecord | undefined; @@ -184,6 +188,10 @@ export interface SlotServiceOptions { readonly googletag: GoogletagAdapter; readonly now?: () => number; readonly reconciliation?: SlotReconciliationBoundary; + readonly warnPublisherHandoffMismatch?: ( + message: string, + details: Readonly<{ formatsMismatch: boolean; pathMismatch: boolean }> + ) => void; } export type SlotReconciliationResolution = @@ -250,6 +258,7 @@ interface ReconciliationWindow { firstPassFinished: boolean; operation: GoogletagOperation | undefined; readonly orphan: PhysicalSlot; + pendingFailureReason: SlotRequestFailure | undefined; terminal: boolean; } @@ -569,6 +578,15 @@ const failed = (reason: SlotRequestFailure): SlotRequestOutcome => const cancelled = (reason: 'navigation_disposed' | 'superseded'): SlotRequestOutcome => Object.freeze({ status: 'cancelled' as const, reason }); +function externalInvocationFailure(error: unknown): SlotRequestFailure { + if (error instanceof GoogletagAdapterError) { + if (error.code === 'external_queue_full' || error.code === 'external_ready_timeout') { + return error.code; + } + } + return 'gpt_request_failed'; +} + function placementKeysFor( registeredSlotId: string, adUnitCode: string | undefined, @@ -1104,6 +1122,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const cancelIntent = (intent: RequestIntent): void => { if (intent.terminal) return; + if (intent.record.reconciliation?.pendingFailureReason !== undefined) { + settle(intent, cancelled('superseded')); + return; + } const wasInvoked = intent.requestStartedAt !== undefined; const physical = intent.record.physical; if (intent.state === 'cycle' && physical?.activeCycle?.intent === intent) { @@ -1168,18 +1190,24 @@ export function createSlotService(options: SlotServiceOptions): SlotService { intent.requestTimer = setTimeout(() => onRequestTimeout(intent), GPT_REQUEST_START_TIMEOUT_MS); }; - const failExternalInvocation = (record: InternalSlotRecord, intent: RequestIntent): void => { + const failExternalInvocation = ( + record: InternalSlotRecord, + intent: RequestIntent, + error: unknown + ): void => { if (intent.terminal) return; + if (record.reconciliation?.pendingFailureReason !== undefined) return; + const reason = externalInvocationFailure(error); const physical = record.physical; if (physical?.activeCycle?.intent === intent) { physical.activeCycle.intent = undefined; physical.state = 'quarantined'; physical.quarantineReason = 'completion'; - settle(intent, failed('gpt_request_failed')); + settle(intent, failed(reason)); return; } const wasInvoked = intent.requestStartedAt !== undefined; - settle(intent, failed('gpt_request_failed')); + settle(intent, failed(reason)); if (wasInvoked && physical) recoverRequestTimeout(record, physical); else advanceQueued(record); }; @@ -1243,6 +1271,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { intent: RequestIntent, expectedBindingToken: object ): void { + if (record.reconciliation?.pendingFailureReason !== undefined) return; if ( intent.terminal || record.activeIntent !== intent || @@ -1296,12 +1325,12 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (intent.terminal) operation.dispose(); void operation.result.then( () => undefined, - () => { - failExternalInvocation(record, intent); + (error: unknown) => { + failExternalInvocation(record, intent, error); } ); - } catch { - failExternalInvocation(record, intent); + } catch (error) { + failExternalInvocation(record, intent, error); } } @@ -1314,9 +1343,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { provisionalSubscriptions = ensureBindingSubscriptions(gpt); return provisionalSubscriptions; }); - } catch { + } catch (error) { if (provisionalSubscriptions?.installed) provisionalSubscriptions.ownership.release(); - failExternalInvocation(record, intent); + failExternalInvocation(record, intent, error); return; } intent.invocation = operation; @@ -1325,12 +1354,13 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (intent.invocation === operation) intent.invocation = undefined; retireHistoricalSubscriptions(subscriptions.ownership); if (intent.terminal) return; + if (record.reconciliation?.pendingFailureReason !== undefined) return; invokeExternalIntent(record, intent, subscriptions.ownership.token); }, - () => { + (error: unknown) => { if (intent.invocation === operation) intent.invocation = undefined; if (provisionalSubscriptions?.installed) provisionalSubscriptions.ownership.release(); - failExternalInvocation(record, intent); + failExternalInvocation(record, intent, error); } ); } @@ -1509,25 +1539,33 @@ export function createSlotService(options: SlotServiceOptions): SlotService { physical.activeCycle = undefined; }; - const retireFailedReconciliation = ( + const pauseReconciliationIntent = (intent: RequestIntent | undefined): void => { + if (!intent || intent.terminal) return; + if (intent.requestTimer !== undefined) clearTimeout(intent.requestTimer); + if (intent.completionTimer !== undefined) clearTimeout(intent.completionTimer); + intent.requestTimer = undefined; + intent.completionTimer = undefined; + intent.requestDeadlineAt = undefined; + intent.completionDeadlineAt = undefined; + }; + + const finishFailedReconciliation = ( record: InternalSlotRecord, window: ReconciliationWindow, reason: SlotRequestFailure, - transactionStarted: boolean, oldSlotDestroyed: boolean ): void => { - if (window.terminal) return; + if (window.terminal || record.reconciliation !== window) return; window.terminal = true; clearReconciliationTimers(window); window.operation?.dispose(); window.operation = undefined; - if (record.reconciliation === window) record.reconciliation = undefined; + record.reconciliation = undefined; const physical = window.orphan; - if (record.physical !== physical || physical.ownership !== 'trusted_server') return; settleReconciliationWork(record, physical, reason); retireCommittedArtifact(record, physical); - record.physical = undefined; + if (record.physical === physical) record.physical = undefined; physical.record = undefined; physical.state = 'retired'; physical.quarantineReason = 'request'; @@ -1538,9 +1576,43 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return; } quarantinePhysicalPlacement(physical); - if (transactionStarted) return; + }; - let destroyOperation: GoogletagOperation | undefined; + const retireFailedReconciliation = ( + record: InternalSlotRecord, + window: ReconciliationWindow, + reason: SlotRequestFailure, + transactionStarted: boolean, + oldSlotDestroyed: boolean + ): void => { + if (window.terminal || record.reconciliation !== window) return; + if (window.pendingFailureReason !== undefined) return; + if (transactionStarted || oldSlotDestroyed) { + finishFailedReconciliation(record, window, reason, oldSlotDestroyed); + return; + } + const physical = window.orphan; + if (record.physical !== physical || physical.ownership !== 'trusted_server') { + cancelReconciliation(record); + return; + } + + window.pendingFailureReason = reason; + clearReconciliationTimers(window); + window.operation?.dispose(); + window.operation = undefined; + pauseReconciliationIntent(physical.activeCycle?.intent); + pauseReconciliationIntent(record.activeIntent); + pauseReconciliationIntent(record.queuedIntent); + physical.activeCycle = undefined; + retireCommittedArtifact(record, physical); + physical.state = 'retired'; + physical.quarantineReason = 'request'; + physical.destroyAttempted = true; + quarantinePhysicalPlacement(physical); + deleteSetValue(physicalSlots, physical); + + let destroyOperation: GoogletagOperation | undefined; try { destroyOperation = options.googletag.run((gpt) => gpt.transactionalReplace( @@ -1552,12 +1624,28 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } ) ); + window.operation = destroyOperation; void destroyOperation.result.then( - () => detachDestroyedReconciliationPhysical(physical), - () => undefined + (result) => { + if (result.status === 'destroyed') { + finishFailedReconciliation(record, window, reason, true); + return; + } + finishFailedReconciliation(record, window, 'gpt_request_failed', true); + }, + (error: unknown) => { + const replacementError = error instanceof GoogletagReplacementError ? error : undefined; + const reusedOldIdentity = replacementError?.orphanedSlot === physical.slot; + const destroyed = + replacementError?.oldSlotDestroyed === true && + replacementError.preserveOldQuarantine !== true && + !reusedOldIdentity; + finishFailedReconciliation(record, window, 'gpt_request_failed', destroyed); + } ); } catch { destroyOperation?.dispose(); + finishFailedReconciliation(record, window, 'gpt_request_failed', false); } }; @@ -1745,6 +1833,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { firstPassFinished: true, operation: undefined, orphan: physical, + pendingFailureReason: undefined, terminal: false, }; record.reconciliation = instant; @@ -1760,6 +1849,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { firstPassFinished: false, operation: undefined, orphan: physical, + pendingFailureReason: undefined, terminal: false, }; record.reconciliation = window; @@ -2491,10 +2581,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } gpt.refresh(slots, Object.freeze({ changeCorrelator: false })); }); - } catch { + } catch (error) { for (let index = 0; index < intents.length; index += 1) { const intent = intents[index]; - if (intent) failExternalInvocation(intent.record, intent); + if (intent) failExternalInvocation(intent.record, intent, error); } return; } @@ -2521,27 +2611,27 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (remaining === 0) operation.dispose(); void operation.result.then( () => undefined, - () => { + (error: unknown) => { for (let index = 0; index < intents.length; index += 1) { const intent = intents[index]; - if (intent) failExternalInvocation(intent.record, intent); + if (intent) failExternalInvocation(intent.record, intent, error); } } ); }, - () => { + (error: unknown) => { if (provisionalSubscriptions?.installed) provisionalSubscriptions.ownership.release(); for (let index = 0; index < intents.length; index += 1) { const intent = intents[index]; - if (intent) failExternalInvocation(intent.record, intent); + if (intent) failExternalInvocation(intent.record, intent, error); } } ); - } catch { + } catch (error) { if (provisionalSubscriptions?.installed) provisionalSubscriptions.ownership.release(); for (let index = 0; index < intents.length; index += 1) { const intent = intents[index]; - if (intent) failExternalInvocation(intent.record, intent); + if (intent) failExternalInvocation(intent.record, intent, error); } } } @@ -2681,6 +2771,22 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (!physical || !record || !record.state.owner.isCurrent()) { return Object.freeze({ action: 'forward' }); } + if (exact.length === 1) { + const definition = physical.definition; + const formatsMismatch = + definition !== undefined && !replacementSizesEqual(sizes, definition.sizes); + const pathMismatch = definition !== undefined && adUnitPath !== definition.adUnitPath; + if (formatsMismatch || pathMismatch) { + try { + options.warnPublisherHandoffMismatch?.( + 'GPT publisher handoff metadata mismatch', + Object.freeze({ formatsMismatch, pathMismatch }) + ); + } catch { + // Diagnostics cannot block an exact publisher ownership handoff. + } + } + } const definitionElementId = physical.definition?.elementId; const aliases = definitionElementId === undefined || definitionElementId === elementId @@ -2756,30 +2862,31 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }; const service: SlotService = Object.freeze({ - activate: (): GoogletagOperation => { - if (activation) return activation; - if (!reconciliationActive) { - const states = mapValueSnapshot(navigationStates); - const installed: NavigationState[] = []; - for (let index = 0; index < states.length; index += 1) { - const state = states[index]; - if (!state || !installNavigationObserver(state)) { - for (let releaseIndex = installed.length - 1; releaseIndex >= 0; releaseIndex -= 1) { - const installedState = installed[releaseIndex]; - const release = installedState?.observerRelease; - if (installedState) installedState.observerRelease = undefined; - try { - release?.(); - } catch { - // Failed activation retains no observer ownership. - } + activate: (): void => { + if (reconciliationActive) return; + const states = mapValueSnapshot(navigationStates); + const installed: NavigationState[] = []; + for (let index = 0; index < states.length; index += 1) { + const state = states[index]; + if (!state || !installNavigationObserver(state)) { + for (let releaseIndex = installed.length - 1; releaseIndex >= 0; releaseIndex -= 1) { + const installedState = installed[releaseIndex]; + const release = installedState?.observerRelease; + if (installedState) installedState.observerRelease = undefined; + try { + release?.(); + } catch { + // Failed activation retains no observer ownership. } - throw new Error('reconciliation observer failed'); } - if (state.observerRelease) installed[installed.length] = state; + throw new Error('reconciliation observer failed'); } - reconciliationActive = true; + if (state.observerRelease) installed[installed.length] = state; } + reconciliationActive = true; + }, + start: (): GoogletagOperation => { + if (activation) return activation; let subscriptions: BindingSubscriptionAdmission | undefined; const operation = options.googletag.run((gpt) => { if (disposed) return; diff --git a/crates/trusted-server-js/lib/src/services/targeting.ts b/crates/trusted-server-js/lib/src/services/targeting.ts index d61d86205..74c795529 100644 --- a/crates/trusted-server-js/lib/src/services/targeting.ts +++ b/crates/trusted-server-js/lib/src/services/targeting.ts @@ -49,6 +49,7 @@ interface TargetingFrame { readonly installed: string; readonly key: string; readonly ownerId: string; + readonly observation: GoogletagTargetingObservation | undefined; readonly slot: object; } @@ -137,6 +138,7 @@ function copyValues(values: readonly string[]): readonly string[] { /** Construct the runtime-owned GPT targeting restoration journal. */ export function createTargetingService(): TargetingService { const chainsBySlot = new WeakMap>(); + const observationsBySlot = new WeakMap(); const liveFrames = new Set(); const observationReleases = new Set<() => void>(); const setAddIntrinsic = Set.prototype.add; @@ -233,6 +235,14 @@ export function createTargetingService(): TargetingService { } }; + const observationIsCurrent = (observation: GoogletagTargetingObservation): boolean => { + try { + return observation.isCurrent() === true; + } catch { + return false; + } + }; + const release = (frame: TargetingFrame): boolean => { if (!frame.alive) return true; const slotChains = weakMapValue(chainsBySlot, frame.slot); @@ -257,6 +267,11 @@ export function createTargetingService(): TargetingService { return true; } + if (frame.observation && !observationIsCurrent(frame.observation)) { + invalidateChain(frame.slot, slotChains, frame.key, chain); + return true; + } + const wasTop = frameIndex === chain.frames.length - 1; if (!wasTop) { removeFrame(frame, slotChains, chain, frameIndex); @@ -358,6 +373,11 @@ export function createTargetingService(): TargetingService { } const actual = copyValues(targeting.getTargeting(key)); + const observation = weakMapValue(observationsBySlot, slot); + if (observation && !observationIsCurrent(observation)) { + invalidatePublisherMutation(slot); + return undefined; + } let slotChains = weakMapValue(chainsBySlot, slot); let chain = slotChains ? mapValue(slotChains, key) : undefined; const top = chain?.frames[chain.frames.length - 1]; @@ -381,6 +401,7 @@ export function createTargetingService(): TargetingService { boundary: targeting, installed: value, key, + observation, ownerId, slot, }; @@ -476,7 +497,7 @@ export function createTargetingService(): TargetingService { let ownedRelease = (): void => undefined; const operation = adapter.run((gpt) => { if (disposed) return; - let release = gpt.observeTargeting( + const release = gpt.observeTargeting( slot, Object.freeze({ beforePublisherMutation: (mutatedSlot: object, key?: string) => { @@ -489,11 +510,14 @@ export function createTargetingService(): TargetingService { if (!active) return; active = false; deleteObservationRelease(ownedRelease); + if (weakMapValue(observationsBySlot, slot) === release) { + deleteWeakMapValue(observationsBySlot, slot); + } const current = release; - release = (): void => undefined; current(); }; try { + setWeakMapValue(observationsBySlot, slot, release); addObservationRelease(ownedRelease); } catch (error) { ownedRelease(); @@ -519,4 +543,8 @@ export function createTargetingService(): TargetingService { snapshotForTest: () => Object.freeze({ frames: frameCount, slots: slotCount }), }); } -import type { GoogletagAdapter, GoogletagOperation } from '../adapters/googletag'; +import type { + GoogletagAdapter, + GoogletagOperation, + GoogletagTargetingObservation, +} from '../adapters/googletag'; 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 index 4f9e6b0ce..57fed654c 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -474,6 +474,8 @@ describe('transactional GPT integration module', () => { [{ 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', @@ -569,7 +571,7 @@ describe('ordered GPT winner publication', () => { getTargeting: (target: object, key: string) => (target as typeof slot).getTargeting(key), observeTargeting: () => { order.push('observe'); - return vi.fn(); + return Object.assign(vi.fn(), { isCurrent: () => true }); }, refresh: vi.fn(), serviceState: () => 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 index 9de10d2f9..99b207791 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts @@ -1,14 +1,16 @@ import { describe, expect, it, vi } from 'vitest'; -import type { - GoogletagAdapter, - GoogletagPublisherCallObserver, +import { + createBrowserGoogletagAdapter, + type GoogletagAdapter, + type GoogletagPublisherCallObserver, } from '../../../src/adapters/googletag'; import { createGptStartup } from '../../../src/integrations/gpt/startup'; -import type { SlotService } from '../../../src/services/slots'; +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) => { @@ -22,17 +24,27 @@ describe('GPT startup bridge', () => { 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(); + 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?.({ @@ -53,6 +65,28 @@ describe('GPT startup bridge', () => { 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(); }); }); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 642907875..ce5e13ccd 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -68,7 +68,7 @@ function createGptHarness( clearTargeting: vi.fn(), display, getTargeting: vi.fn(() => []), - observeTargeting: () => vi.fn(), + observeTargeting: () => Object.assign(vi.fn(), { isCurrent: () => true }), refresh: options.missingRefresh ? (undefined as unknown as GoogletagFacade['refresh']) : refresh, @@ -486,7 +486,13 @@ describe('slot registry', () => { it('hands an exact late publisher definition the TS slot and consumes only duplicate requests', async () => { const gpt = createGptHarness({ initialLoadDisabled: true }); - const service = createSlotService({ googletag: gpt.adapter }); + 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); @@ -498,6 +504,13 @@ describe('slot registry', () => { 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' }); @@ -535,15 +548,37 @@ describe('slot registry', () => { 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('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( @@ -585,6 +620,7 @@ describe('slot registry', () => { 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', () => { @@ -879,6 +915,130 @@ describe('navigation-owned DOM reconciliation', () => { 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(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const { navigation, runtime } = createRuntimeWithNavigation(); + 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); + expect(runtime.replaceNavigation().ok).toBe(true); + await expect(request.result).resolves.toEqual({ + status: 'cancelled', + reason: 'navigation_disposed', + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(request.status).toBe('terminal'); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + }); + + 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); @@ -1641,19 +1801,23 @@ function readyListenerBinding() { } describe('binding-aware GPT listener activation', () => { - it('retries after readiness timeout and never duplicates listeners on the recovered binding', async () => { + 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 }); - const missing = service.activate(); + 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.activate().result).resolves.toBeUndefined(); - await expect(service.activate().result).resolves.toBeUndefined(); + await expect(service.start().result).resolves.toBeUndefined(); + await expect(service.start().result).resolves.toBeUndefined(); expect(ready.addEventListener.mock.calls.map(([type]) => type)).toEqual([ 'slotRequested', @@ -1667,10 +1831,11 @@ describe('binding-aware GPT listener activation', () => { const target: { googletag?: unknown } = { googletag: first.binding }; const adapter = createBrowserGoogletagAdapter(target); const service = createSlotService({ googletag: adapter }); - await expect(service.activate().result).resolves.toBeUndefined(); + service.activate(); + await expect(service.start().result).resolves.toBeUndefined(); target.googletag = second.binding; - await expect(service.activate().result).resolves.toBeUndefined(); - await expect(service.activate().result).resolves.toBeUndefined(); + await expect(service.start().result).resolves.toBeUndefined(); + await expect(service.start().result).resolves.toBeUndefined(); expect(first.addEventListener).toHaveBeenCalledTimes(2); expect(second.addEventListener).toHaveBeenCalledTimes(2); @@ -1684,6 +1849,59 @@ describe('binding-aware GPT listener activation', () => { 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(); diff --git a/crates/trusted-server-js/lib/test/services/targeting.test.ts b/crates/trusted-server-js/lib/test/services/targeting.test.ts index cf472f9da..ffd7e9aca 100644 --- a/crates/trusted-server-js/lib/test/services/targeting.test.ts +++ b/crates/trusted-server-js/lib/test/services/targeting.test.ts @@ -205,6 +205,67 @@ describe('owner-aware targeting journal', () => { clearAll?.release(); expect(values.size).toBe(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 }); + } + ); }); function adapterForTargetingSlot(slot: object) { @@ -291,6 +352,43 @@ describe('adapter-owned targeting interception', () => { 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(); From cc47b15286d4580636f7ffd76247448da15173e7 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:15:25 -0700 Subject: [PATCH 113/194] Complete browser integration lifecycle wiring --- .../lib/src/composition/browser.ts | 30 +++- .../lib/test/composition/browser.test.ts | 141 +++++++++++++++++- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index de40a8133..9257e4c3f 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -20,7 +20,7 @@ import { } from '../adapters/prebid'; import { parseCacheFetchPolicyV1 } from '../core/config'; import { parseTrustedServerAuctionResponseV1 } from '../core/auction'; -import type { BrowserAuctionProjectionV1 } from '../core/types'; +import type { BrowserAuctionProjectionV1, CreativeBootV1 } from '../core/types'; import { parseBidRenderSourceV1, parseBrowserAuctionProjectionV1, @@ -36,6 +36,10 @@ import { } 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 { publishGptWinner, startGptSlotOperation, @@ -169,6 +173,8 @@ export interface BrowserCoreActivations { 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; @@ -179,6 +185,7 @@ export interface TestBrowserRuntimeCompositionOptions extends BrowserComposition interface AcceptedBrowserBoot { readonly auctionProjection: object; readonly cachePolicy?: unknown; + readonly creative: Readonly; readonly manifest: { readonly integrations: readonly { readonly id: string }[]; }; @@ -269,6 +276,23 @@ export function createTestBrowserRuntimeComposition( const composition = createBrowserComposition(compositionOptions); const providedBindings = runtimeOptions.getBindings; let browserServices: Readonly | undefined; + let creativeBoot: Readonly | undefined; + 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 gptRuntime = createGptStartup({ googletag: composition.adapters.googletag, @@ -358,6 +382,7 @@ export function createTestBrowserRuntimeComposition( if (!descriptor || !('value' in descriptor)) return provided; config = descriptor.value; } + if (id === 'creative' && config === undefined) config = creativeBoot; const interfaces = runtimeSession?.interfaces; if (!interfaces) throw new Error(`Integration interfaces are unavailable for ${id}`); return Object.freeze({ @@ -555,6 +580,7 @@ export function createTestBrowserRuntimeComposition( }, prepareOwner: (context) => { const boot = context.boot as unknown as AcceptedBrowserBoot; + creativeBoot = boot.creative; const cachePolicy = boot.cachePolicy === undefined ? undefined : parseCacheFetchPolicyV1(boot.cachePolicy); const parseProjection = (candidate: unknown): object | undefined => @@ -758,6 +784,7 @@ export function createTestBrowserRuntimeComposition( compositionOptions.createIdentityIssuerForTest ?? createBrowserNavigationIdentityIssuer, interfaces: Object.freeze({ adapters: composition.adapters, + creative: creativeRuntime, gpt: gptRuntime, prebid: prebidRuntime, ...services, @@ -782,6 +809,7 @@ export function createTestBrowserRuntimeComposition( auctionBatchService = undefined; auctionContextRegistry = undefined; projectionParser = undefined; + creativeBoot = undefined; } }); const navigation = session.startInitialNavigation(initialProjection); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index ee57a3e28..16b530e4d 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -29,6 +29,7 @@ import { } from '../../src/composition/browser'; import { log as localLog } from '../../src/core/log'; import type { BrowserAuctionBidV1 } from '../../src/core/types'; +import { createCreativeIntegrationRegistration } from '../../src/integrations/creative/module'; import { createGptIntegrationRegistration } from '../../src/integrations/gpt/module'; import { isGuardInstalled, resetGuardState } from '../../src/integrations/gpt/script_guard'; import { createPrebidIntegrationRegistration } from '../../src/integrations/prebid/module'; @@ -73,7 +74,7 @@ function synchronousGptAdapter() { getTargeting: vi.fn((slot: object, key: string) => Object.freeze([...(targeting.get(slot)?.get(key) ?? [])]) ), - observeTargeting: () => vi.fn(), + observeTargeting: () => Object.assign(vi.fn(), { isCurrent: () => true }), refresh, serviceState: () => Object.freeze({ apiReady: true, initialLoadDisabled: false, pubadsReady: true }), @@ -603,7 +604,8 @@ describe('browser composition', () => { expect(Object.isFrozen(composition.runtime)).toBe(true); }); - it('subscribes the injected slot service before correctness activation and disposes both listeners', async () => { + it('starts slot listeners before post-commit GPT startup and disposes both listeners', async () => { + const releaseId = 'a'.repeat(64); const subscriptions: string[] = []; const releases: string[] = []; const facade = { @@ -629,16 +631,20 @@ describe('browser composition', () => { _adapters: unknown, services: { readonly slots: { readonly snapshotForTest: () => { records: number } } } ) => { - expect(subscriptions).toEqual(['slotRequested', 'slotRenderEnded']); + expect(subscriptions).toEqual([]); expect(services.slots.snapshotForTest().records).toBe(0); } ); const composition = createTestBrowserRuntimeComposition( { target: {}, - releaseId: 'a'.repeat(64), - manifest: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, - knownIntegrationIds: Object.freeze([]), + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [{ id: 'gpt', required: true }], + }, + knownIntegrationIds: Object.freeze(['gpt']), boot: { auctionProjection: { version: 1, @@ -662,10 +668,16 @@ describe('browser composition', () => { 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(); @@ -752,6 +764,123 @@ describe('browser composition', () => { expect(isGuardInstalled()).toBe(false); }); + 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: { + version: 1, + releaseId, + integrations: [{ id: 'creative', required: true }], + }, + knownIntegrationIds: Object.freeze(['creative']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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(createCreativeIntegrationRegistration(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('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: { + version: 1, + releaseId, + integrations: [{ id: 'creative', required: true }], + }, + knownIntegrationIds: Object.freeze(['creative']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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(createCreativeIntegrationRegistration(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(); From e82f3e798e7e19456d50fb802a7a7ab4cd0222e3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:20:27 -0700 Subject: [PATCH 114/194] Add bounded kernel diagnostics transport --- .../lib/src/kernel/diagnostics.ts | 222 ++++++++++++++++++ .../lib/test/kernel/diagnostics.test.ts | 154 ++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 crates/trusted-server-js/lib/src/kernel/diagnostics.ts create mode 100644 crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts diff --git a/crates/trusted-server-js/lib/src/kernel/diagnostics.ts b/crates/trusted-server-js/lib/src/kernel/diagnostics.ts new file mode 100644 index 000000000..281e1f2c7 --- /dev/null +++ b/crates/trusted-server-js/lib/src/kernel/diagnostics.ts @@ -0,0 +1,222 @@ +import type { BootManifestV1 } from '../core/types'; + +const MAX_INTEGRATION_SUBSCRIPTIONS = 16; +const MAX_PENDING_OBSERVATIONS = 512; +const MAX_OBSERVATION_DEPTH = 16; +const MAX_OBSERVATION_NODES = 512; +const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +export type DiagnosticsObservation = Readonly>; +export type DiagnosticsListener = (observation: DiagnosticsObservation) => void; + +export interface DiagnosticsScheduler { + readonly set: (callback: () => void, milliseconds: number) => unknown; + readonly clear: (handle: unknown) => void; +} + +export interface DiagnosticsBusOptions { + readonly manifest: Readonly; + readonly onOverflow?: (droppedObservations: number) => void; + readonly onSubscriberError?: (error: unknown) => void; + readonly pendingCapacity?: number; + readonly scheduler?: DiagnosticsScheduler; +} + +export interface DiagnosticsBus { + readonly publish: (observation: DiagnosticsObservation) => boolean; + readonly subscribe: (id: string, listener: DiagnosticsListener) => (() => void) | undefined; + readonly dispose: () => void; +} + +interface Subscription { + readonly id: string; + readonly listener: DiagnosticsListener; + active: boolean; +} + +interface PendingObservation { + readonly observation: DiagnosticsObservation; + readonly subscriptions: readonly Subscription[]; +} + +function defaultScheduler(): DiagnosticsScheduler { + return Object.freeze({ + clear: (handle: unknown): void => { + globalThis.clearTimeout(handle as ReturnType); + }, + set: (callback: () => void, milliseconds: number): unknown => + globalThis.setTimeout(callback, milliseconds), + }); +} + +function recursivelyFrozenRecord(candidate: unknown): candidate is DiagnosticsObservation { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return false; + const visited = new Set(); + let nodes = 0; + const visit = (value: unknown, depth: number): boolean => { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return true; + if (visited.has(value)) return true; + if (depth > MAX_OBSERVATION_DEPTH || nodes >= MAX_OBSERVATION_NODES) return false; + visited.add(value); + nodes += 1; + try { + if (typeof value === 'function') return true; + const prototype = Object.getPrototypeOf(value) as unknown; + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) { + // GPT physical-slot objects are opaque identities, not diagnostic data. + return true; + } + if (!Object.isFrozen(value)) return false; + const keys = Reflect.ownKeys(value); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (key === undefined) return false; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor) || !visit(descriptor.value, depth + 1)) { + return false; + } + } + return true; + } catch { + return false; + } + }; + return visit(candidate, 0); +} + +/** Create the closure-private, failure-isolated diagnostics transport for one runtime. */ +export function createDiagnosticsBus(options: DiagnosticsBusOptions): DiagnosticsBus { + const allowedIds = new Set(); + try { + const integrationsDescriptor = Object.getOwnPropertyDescriptor( + options.manifest, + 'integrations' + ); + const integrations = + integrationsDescriptor && 'value' in integrationsDescriptor + ? (integrationsDescriptor.value as readonly unknown[]) + : []; + for (let index = 0; index < integrations.length; index += 1) { + const entry = integrations[index]; + if (typeof entry !== 'object' || entry === null) continue; + const idDescriptor = Object.getOwnPropertyDescriptor(entry, 'id'); + const id = idDescriptor && 'value' in idDescriptor ? idDescriptor.value : undefined; + if (typeof id === 'string' && INTEGRATION_ID.test(id)) allowedIds.add(id); + } + } catch { + // Invalid manifest identities admit no diagnostic consumers. + } + const pendingCapacity = + Number.isSafeInteger(options.pendingCapacity) && + (options.pendingCapacity ?? 0) > 0 && + (options.pendingCapacity ?? 0) <= MAX_PENDING_OBSERVATIONS + ? options.pendingCapacity! + : MAX_PENDING_OBSERVATIONS; + const scheduler = options.scheduler ?? defaultScheduler(); + const subscriptions = new Map(); + const pending: PendingObservation[] = []; + let disposed = false; + let droppedObservations = 0; + let scheduled = false; + let scheduledHandle: unknown; + + const reportSubscriberError = (error: unknown): void => { + try { + options.onSubscriberError?.(error); + } catch { + // Diagnostics error reporting is observation only. + } + }; + + const drain = (): void => { + scheduled = false; + scheduledHandle = undefined; + if (disposed) { + pending.length = 0; + return; + } + while (pending.length > 0 && !disposed) { + const item = pending.shift(); + if (!item) continue; + for (let index = 0; index < item.subscriptions.length; index += 1) { + const subscription = item.subscriptions[index]; + if (!subscription?.active || subscriptions.get(subscription.id) !== subscription) { + continue; + } + try { + subscription.listener(item.observation); + } catch (error) { + reportSubscriberError(error); + } + } + } + }; + + const scheduleDrain = (): boolean => { + if (scheduled) return true; + scheduled = true; + try { + const handle = scheduler.set(drain, 0); + if (scheduled) scheduledHandle = handle; + return true; + } catch { + scheduled = false; + scheduledHandle = undefined; + pending.length = 0; + return false; + } + }; + + return Object.freeze({ + publish: (observation: DiagnosticsObservation): boolean => { + if (disposed || !recursivelyFrozenRecord(observation)) return false; + const captured = Object.freeze([...subscriptions.values()]); + if (captured.length === 0) return true; + if (pending.length >= pendingCapacity) { + pending.shift(); + droppedObservations += 1; + try { + options.onOverflow?.(droppedObservations); + } catch { + // Diagnostics overflow accounting cannot affect correctness work. + } + } + pending.push(Object.freeze({ observation, subscriptions: captured })); + return scheduleDrain(); + }, + subscribe: (id: string, listener: DiagnosticsListener): (() => void) | undefined => { + if ( + disposed || + typeof listener !== 'function' || + !allowedIds.has(id) || + subscriptions.has(id) || + subscriptions.size >= MAX_INTEGRATION_SUBSCRIPTIONS + ) { + return undefined; + } + const subscription: Subscription = { id, listener, active: true }; + subscriptions.set(id, subscription); + return (): void => { + if (!subscription.active) return; + subscription.active = false; + if (subscriptions.get(id) === subscription) subscriptions.delete(id); + }; + }, + dispose: (): void => { + if (disposed) return; + disposed = true; + for (const subscription of subscriptions.values()) subscription.active = false; + subscriptions.clear(); + pending.length = 0; + if (scheduled) { + scheduled = false; + try { + scheduler.clear(scheduledHandle); + } catch { + // The disposed flag suppresses a hostile late scheduler callback. + } + } + scheduledHandle = undefined; + }, + }); +} 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..0b02a85f8 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { BootManifestV1 } from '../../src/core/types'; +import { createDiagnosticsBus, type DiagnosticsObservation } from '../../src/kernel/diagnostics'; + +const RELEASE_ID = 'a'.repeat(64); + +function manifest(ids: readonly string[]): BootManifestV1 { + return Object.freeze({ + version: 1, + releaseId: RELEASE_ID, + integrations: Object.freeze(ids.map((id) => Object.freeze({ id, required: true as const }))), + }); +} + +function observation(sequence: number): DiagnosticsObservation { + return Object.freeze({ + kind: 'render', + sequence, + value: Object.freeze({ slotId: `slot-${sequence}` }), + }); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('kernel diagnostics bus', () => { + it('exposes only a frozen private-owner facade', () => { + const bus = createDiagnosticsBus({ manifest: manifest([]) }); + + expect(Object.isFrozen(bus)).toBe(true); + expect(Reflect.ownKeys(bus).sort()).toEqual(['dispose', 'publish', 'subscribe']); + expect('listeners' in bus).toBe(false); + expect('pending' in bus).toBe(false); + + bus.dispose(); + }); + + it('admits only one live subscription for an exact manifest member', () => { + const bus = createDiagnosticsBus({ manifest: manifest(['gpt_diagnostics']) }); + const first = bus.subscribe('gpt_diagnostics', vi.fn()); + + expect(first).toEqual(expect.any(Function)); + expect(bus.subscribe('gpt_diagnostics', vi.fn())).toBeUndefined(); + expect(bus.subscribe('not_in_manifest', vi.fn())).toBeUndefined(); + + first?.(); + expect(bus.subscribe('gpt_diagnostics', vi.fn())).toEqual(expect.any(Function)); + bus.dispose(); + }); + + it('admits sixteen live module identities and rejects a seventeenth without disturbance', () => { + vi.useFakeTimers(); + const ids = Array.from({ length: 17 }, (_, index) => `module_${index}`); + const bus = createDiagnosticsBus({ manifest: manifest(ids) }); + const listeners = ids.map(() => vi.fn()); + + for (let index = 0; index < 16; index += 1) { + expect(bus.subscribe(ids[index]!, listeners[index]!)).toEqual(expect.any(Function)); + } + expect(bus.subscribe(ids[16]!, listeners[16]!)).toBeUndefined(); + + expect(bus.publish(observation(1))).toBe(true); + expect(listeners.every((listener) => listener.mock.calls.length === 0)).toBe(true); + vi.runOnlyPendingTimers(); + expect(listeners.slice(0, 16).every((listener) => listener.mock.calls.length === 1)).toBe(true); + expect(listeners[16]).not.toHaveBeenCalled(); + bus.dispose(); + }); + + it('delivers frozen observations asynchronously in order and isolates subscriber throws', () => { + vi.useFakeTimers(); + const errors: unknown[] = []; + const bus = createDiagnosticsBus({ + manifest: manifest(['thrower', 'observer']), + onSubscriberError: (error) => errors.push(error), + }); + const received: number[] = []; + bus.subscribe('thrower', () => { + throw new Error('fictional diagnostics failure'); + }); + bus.subscribe('observer', (event) => { + expect(Object.isFrozen(event)).toBe(true); + if (typeof event.sequence === 'number') received.push(event.sequence); + }); + + expect(bus.publish(observation(1))).toBe(true); + expect(bus.publish(observation(2))).toBe(true); + expect(received).toEqual([]); + + vi.runOnlyPendingTimers(); + expect(received).toEqual([1, 2]); + expect(errors).toHaveLength(2); + bus.dispose(); + }); + + it('uses publish-time membership while honoring unsubscribe before delivery', () => { + vi.useFakeTimers(); + const bus = createDiagnosticsBus({ manifest: manifest(['first', 'second']) }); + const first = vi.fn(); + const second = vi.fn(); + const releaseFirst = bus.subscribe('first', first); + + bus.publish(observation(1)); + const releaseSecond = bus.subscribe('second', second); + releaseFirst?.(); + vi.runOnlyPendingTimers(); + + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + + bus.publish(observation(2)); + vi.runOnlyPendingTimers(); + expect(second).toHaveBeenCalledOnce(); + expect(second).toHaveBeenCalledWith(observation(2)); + releaseSecond?.(); + bus.dispose(); + }); + + it('bounds pending delivery and cancels all work on disposal', () => { + vi.useFakeTimers(); + const bus = createDiagnosticsBus({ + manifest: manifest(['observer']), + pendingCapacity: 2, + }); + const listener = vi.fn(); + bus.subscribe('observer', listener); + + bus.publish(observation(1)); + bus.publish(observation(2)); + bus.publish(observation(3)); + vi.runOnlyPendingTimers(); + + expect(listener.mock.calls.map(([event]) => event.sequence)).toEqual([2, 3]); + + bus.publish(observation(4)); + bus.dispose(); + vi.runOnlyPendingTimers(); + expect(listener).toHaveBeenCalledTimes(2); + expect(bus.publish(observation(5))).toBe(false); + expect(bus.subscribe('observer', vi.fn())).toBeUndefined(); + }); + + it('rejects mutable observations without reading them', () => { + const bus = createDiagnosticsBus({ manifest: manifest([]) }); + const read = vi.fn(); + const mutable = Object.defineProperty({}, 'kind', { enumerable: true, get: read }); + + expect(bus.publish(mutable as DiagnosticsObservation)).toBe(false); + expect(read).not.toHaveBeenCalled(); + bus.dispose(); + }); +}); From e49b0617f028434fec02c572b08bb7a7d78f3e11 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:24:05 -0700 Subject: [PATCH 115/194] Close GPT cleanup race windows --- .../lib/src/services/slots.ts | 8 +++ .../lib/src/services/targeting.ts | 8 +++ .../lib/test/services/slots.test.ts | 55 +++++++++++++++++-- .../lib/test/services/targeting.test.ts | 45 +++++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index e9a348eff..1521f50d6 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -1628,6 +1628,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { void destroyOperation.result.then( (result) => { if (result.status === 'destroyed') { + if (window.terminal || record.reconciliation !== window) { + detachDestroyedReconciliationPhysical(physical); + return; + } finishFailedReconciliation(record, window, reason, true); return; } @@ -1640,6 +1644,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { replacementError?.oldSlotDestroyed === true && replacementError.preserveOldQuarantine !== true && !reusedOldIdentity; + if (destroyed && (window.terminal || record.reconciliation !== window)) { + detachDestroyedReconciliationPhysical(physical); + return; + } finishFailedReconciliation(record, window, 'gpt_request_failed', destroyed); } ); diff --git a/crates/trusted-server-js/lib/src/services/targeting.ts b/crates/trusted-server-js/lib/src/services/targeting.ts index 74c795529..a00daa8f6 100644 --- a/crates/trusted-server-js/lib/src/services/targeting.ts +++ b/crates/trusted-server-js/lib/src/services/targeting.ts @@ -284,6 +284,10 @@ export function createTargetingService(): TargetingService { } catch { return false; } + if (frame.observation && !observationIsCurrent(frame.observation)) { + invalidateChain(frame.slot, slotChains, frame.key, chain); + return true; + } if (!exactInstalledValue(actual, frame.installed)) { invalidateChain(frame.slot, slotChains, frame.key, chain); return true; @@ -291,6 +295,10 @@ export function createTargetingService(): TargetingService { const expected = expectedPredecessor(frame); if (!expected) return false; + if (frame.observation && !observationIsCurrent(frame.observation)) { + invalidateChain(frame.slot, slotChains, frame.key, chain); + return true; + } try { restorePredecessor(frame); } catch { diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index ce5e13ccd..b903026fd 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -42,6 +42,7 @@ function createRuntimeWithNavigation() { function createGptHarness( options: { initialLoadDisabled?: boolean; + deferDestroyedResult?: boolean; missingRefresh?: boolean; orphanOnReplace?: object; returnOldOnReplace?: boolean; @@ -63,6 +64,12 @@ function createGptHarness( const addService = vi.fn(); const operationDisposals: Array> = []; const bindingToken = Object.freeze({}); + 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(), @@ -144,7 +151,20 @@ function createGptHarness( let result: Promise; if (options.synchronousRun !== false) { try { - result = Promise.resolve(command(facade)); + 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); } @@ -173,6 +193,11 @@ function createGptHarness( facade, operationDisposals, refresh, + resolveDeferredDestroyed: () => { + if (deferredDestroyedResolved) return; + deferredDestroyedResolved = true; + resolveDeferredDestroyedPromise(); + }, }; } @@ -965,7 +990,7 @@ describe('navigation-owned DOM reconciliation', () => { it('keeps final cleanup pending and lets navigation cancellation beat its late result', async () => { vi.useFakeTimers(); vi.setSystemTime(0); - const gpt = createGptHarness(); + const gpt = createGptHarness({ deferDestroyedResult: true }); const dom = createReconciliationBoundary(); dom.put('slot-div', {}); const service = createSlotService({ @@ -974,7 +999,7 @@ describe('navigation-owned DOM reconciliation', () => { reconciliation: dom.boundary, }); const { navigation, runtime } = createRuntimeWithNavigation(); - bindTrustedSlot(service, navigation); + const oldSlot = bindTrustedSlot(service, navigation); service.activate(); dom.disconnect('slot-div'); await vi.advanceTimersByTimeAsync(4_999); @@ -989,16 +1014,38 @@ describe('navigation-owned DOM reconciliation', () => { vi.advanceTimersByTime(1); expect(request.status).toBe('active'); expect(gpt.destroySlots).toHaveBeenCalledTimes(1); - expect(runtime.replaceNavigation().ok).toBe(true); + 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 () => { diff --git a/crates/trusted-server-js/lib/test/services/targeting.test.ts b/crates/trusted-server-js/lib/test/services/targeting.test.ts index ffd7e9aca..c637d41bc 100644 --- a/crates/trusted-server-js/lib/test/services/targeting.test.ts +++ b/crates/trusted-server-js/lib/test/services/targeting.test.ts @@ -266,6 +266,51 @@ describe('owner-aware targeting journal', () => { 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) { From 226a08fe90cf63baf3ed7015d2cc0b3e3c6a5da6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:31:33 -0700 Subject: [PATCH 116/194] Add bounded render trace diagnostics --- .../trusted-server-js/lib/src/core/trace.ts | 365 ++++++++++++++++-- .../lib/test/core/trace_runtime.test.ts | 201 ++++++++++ 2 files changed, 534 insertions(+), 32 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/core/trace_runtime.test.ts diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 67c9d07d6..456902f0e 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -1,23 +1,16 @@ // 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 { LegacyTsjsApi, RenderRecord } from './types'; +import type { + LegacyTsjsApi, + RenderRecord, + RenderTraceDiagnostics, + RenderTraceRecord, +} 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). */ @@ -29,23 +22,8 @@ export const TRACE_PANEL_ID = 'ts-render-trace-panel'; * history is trimmed from the front rather than growing without limit. */ 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 LegacyTsjsApi); @@ -450,8 +428,6 @@ export function recordRender(record: Omit) 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); if (history.length > MAX_RENDER_LOG_ENTRIES) { @@ -463,7 +439,6 @@ export function recordRender(record: Omit) 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(); @@ -518,7 +493,6 @@ export function updateRender(record: RenderRecord, patch: RenderUpdate): RenderR 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(); @@ -580,3 +554,330 @@ export function stampCreativeTrace(el: Element, record: RenderRecord): void { log.warn('trace: failed to stamp element', { slotId: record.slotId, err }); } } + +const MAX_RENDER_TRACE_SLOTS = 256; +const MAX_RENDER_TRACE_SUBSCRIBERS = 32; +const MAX_RENDER_TRACE_NOTIFICATIONS = 200; + +type RenderTraceInputV1 = Omit; +type RenderTraceUpdateV1 = Partial>; + +export interface RenderTraceRuntimeScheduler { + readonly set: (callback: () => void, milliseconds: number) => unknown; + readonly clear: (handle: unknown) => void; +} + +export interface RenderTraceRuntimeOptions { + readonly now?: () => number; + readonly onOverflow?: (droppedNotifications: number) => void; + readonly onSubscriberError?: (error: unknown) => void; + readonly schedule?: (callback: () => void) => () => void; + readonly scheduler?: RenderTraceRuntimeScheduler; +} + +export interface RenderTraceRuntimeOwner { + readonly api: RenderTraceDiagnostics; + readonly diagnostics: RenderTraceDiagnostics; + readonly record: (input: RenderTraceInputV1) => Readonly; + readonly enrich: ( + recordOrSequence: Readonly | number, + patch: RenderTraceUpdateV1 + ) => Readonly | undefined; + readonly prune: (slotId: string, sequence?: number) => boolean; + readonly dispose: () => void; +} + +export class DiagnosticsSubscriberLimitError extends Error { + public readonly code = 'subscriber_capacity' as const; + public readonly surface: 'renderTrace' | 'gpt'; + + public constructor(surface: 'renderTrace' | 'gpt') { + super('subscriber_capacity'); + this.name = 'DiagnosticsSubscriberLimitError'; + this.surface = surface; + } +} + +interface RenderTraceSubscription { + readonly id: number; + readonly listener: (record: Readonly) => void; +} + +interface PendingRenderTraceNotification { + readonly record: Readonly; + readonly subscriberIds: readonly number[]; +} + +function copyRenderTraceRecord(record: Readonly): Readonly { + const copy: Record = { + slotId: record.slotId, + path: record.path, + rendered: record.rendered, + }; + const optional = [ + 'elementId', + 'auctionId', + 'bidder', + 'adId', + 'bidId', + 'creativeId', + 'admHash', + 'servedFrom', + 'gamEmpty', + 'injected', + 'visible', + ] as const; + for (const key of optional) { + const value = record[key]; + if (value !== undefined) copy[key] = value; + } + copy.count = record.count; + copy.seq = record.seq; + copy.at = record.at; + return Object.freeze(copy) as unknown as Readonly; +} + +function scheduleRenderTraceTask(callback: () => void): () => void { + const handle = globalThis.setTimeout(callback, 0); + return (): void => globalThis.clearTimeout(handle); +} + +/** Create one document-runtime render trace without exposing its mutation authority. */ +export function createRenderTraceDiagnostics( + options: RenderTraceRuntimeOptions = {} +): RenderTraceRuntimeOwner { + const current = new Map>(); + const history: Array> = []; + const recordsBySequence = new Map>(); + 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 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); + } + return scheduleRenderTraceTask(callback); + }; + + const reportSubscriberError = (error: unknown): void => { + try { + options.onSubscriberError?.(error); + } catch { + // Diagnostics error reporting cannot affect correctness work. + } + }; + + 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 record = (input: RenderTraceInputV1): Readonly => { + const previous = current.get(input.slotId); + let at: number; + try { + at = (options.now ?? Date.now)(); + } catch { + at = Date.now(); + } + const committed = copyRenderTraceRecord({ + ...input, + count: (previous?.count ?? 0) + 1, + seq: (sequence += 1), + at, + }); + if (disposed) return committed; + if (!previous && current.size >= MAX_RENDER_TRACE_SLOTS) { + const oldestSlot = current.keys().next().value as string | undefined; + if (oldestSlot !== undefined) current.delete(oldestSlot); + } + current.set(committed.slotId, committed); + recordsBySequence.set(committed.seq, committed); + history.push(committed); + if (history.length > MAX_RENDER_LOG_ENTRIES) { + const evicted = history.shift(); + if (evicted && !retained(evicted)) recordsBySequence.delete(evicted.seq); + } + if (previous && !retained(previous)) recordsBySequence.delete(previous.seq); + enqueue(committed); + 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); + return committed; + }; + + const prune = (slotId: string, expectedSequence?: number): boolean => { + if (disposed || typeof slotId !== 'string') return false; + const existing = current.get(slotId); + if (!existing || (expectedSequence !== undefined && existing.seq !== expectedSequence)) { + return false; + } + current.delete(slotId); + if (!retained(existing)) recordsBySequence.delete(existing.seq); + return true; + }; + + 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 dispose = (): void => { + if (disposed) return; + disposed = true; + 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(); + }; + + return Object.freeze({ api, diagnostics: api, record, enrich, prune, dispose }); +} + +/** Short name used by the browser composition owner. */ +export const createRenderTrace = createRenderTraceDiagnostics; 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..a5d7671ed --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createRenderTrace, DiagnosticsSubscriberLimitError } from '../../src/core/trace'; + +function harness() { + const tasks: Array<() => void> = []; + const owner = createRenderTrace({ + 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 typeof window & { 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('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 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('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(Object.isFrozen(late)).toBe(true); + 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); + }); +}); From 21f50049774804eea0da402351623aff5b645a50 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:32:18 -0700 Subject: [PATCH 117/194] Fix render trace isolation test typing --- crates/trusted-server-js/lib/test/core/trace_runtime.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a5d7671ed..fef447bc5 100644 --- a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -28,7 +28,7 @@ function harness() { describe('render trace diagnostics runtime', () => { it('exposes one exact frozen read-only public surface with copied snapshots', () => { const { owner } = harness(); - const target = window as typeof window & { tsjs?: Record }; + const target = window as unknown as { tsjs?: Record }; const existingApi = (target.tsjs = {}); const event = vi.fn(); window.addEventListener('tsjs:adRendered', event); From f4814a5ce3a06d7825dbf63c9ed8c450f256dec5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:34:23 -0700 Subject: [PATCH 118/194] Publish terminal render diagnostics --- .../lib/src/services/render.ts | 28 +++++++++ .../lib/test/services/render.test.ts | 57 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index 9d60befb3..acce274d8 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -637,9 +637,18 @@ export interface RenderAttemptOptions { readonly prepareRenderSource: (candidate: unknown) => ReservationRenderSource | undefined; readonly reservations: ReservationService; readonly parentAttemptId?: string; + readonly publishDiagnostics?: (observation: RenderAttemptDiagnosticsObservation) => unknown; readonly scheduler?: RenderScheduler; } +export interface RenderAttemptDiagnosticsObservation { + readonly kind: 'render_attempt'; + readonly attemptId: string; + readonly slotId: string; + readonly state: 'accepted' | 'no_bid' | 'failed' | 'cancelled'; + readonly outcome: RenderOutcome; +} + export type RenderAttemptCreationResult = | Readonly<{ ok: true; value: RenderAttempt }> | Readonly<{ @@ -1189,6 +1198,9 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp let parentAttemptId: string | undefined; let prepareRenderSource: (candidate: unknown) => ReservationRenderSource | undefined; let reservations: ReservationService; + let publishDiagnostics: + | ((observation: RenderAttemptDiagnosticsObservation) => unknown) + | undefined; let consumeClaimMethod: ReservationService['consumeClaim']; let ownerIsCurrentMethod: RenderAttemptScope['isCurrent']; let ownerDisposeMethod: RenderAttemptScope['dispose']; @@ -1229,6 +1241,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp parentAttemptId = options.parentAttemptId; prepareRenderSource = options.prepareRenderSource; reservations = options.reservations; + publishDiagnostics = options.publishDiagnostics; if (!isReservationService(reservations)) { return rejectConstruction('invalid_attempt'); } @@ -1257,6 +1270,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp typeof ownerPrepareWinnerMethod !== 'function' || typeof prepareRenderSource !== 'function' || typeof consumeClaimMethod !== 'function' || + (publishDiagnostics !== undefined && typeof publishDiagnostics !== 'function') || (parentAttemptId !== undefined && (!validAttemptId(parentAttemptId) || parentAttemptId === id)) ) { @@ -1540,6 +1554,20 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp settlingInternally = false; } } + if (publishDiagnostics) { + const observation = frozen({ + kind: 'render_attempt', + attemptId: id, + slotId: slot, + state: terminal.outcome, + outcome: terminal, + }); + try { + Reflect.apply(publishDiagnostics, undefined, [observation]); + } catch { + // Diagnostics publication cannot change terminal render authority. + } + } notify(terminal); return true; }; diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index 1c0d71bce..6caccf0b8 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -26,6 +26,8 @@ import { type DirectAdmIframeConstructor, type DirectAdmIframeHandle, type RenderAttempt, + type RenderAttemptDiagnosticsObservation, + type RenderAttemptSnapshot, type RenderAttemptState, type SlotOperation, type SlotOperationOptions, @@ -337,6 +339,9 @@ function attempt( 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 }); @@ -4541,6 +4546,58 @@ describe('committed artifact ownership', () => { }); }); +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, + state: 'accepted', + outcome: { outcome: 'accepted' }, + }); + }); + + 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(); From 2184738914976c9af66b168edf843cd59d6f1dda Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:40:33 -0700 Subject: [PATCH 119/194] Wire private runtime diagnostics --- .../lib/src/composition/browser.ts | 78 ++++++++++++++++++- .../lib/src/kernel/diagnostics.ts | 7 ++ .../lib/src/kernel/runtime.ts | 13 +++- .../lib/src/services/render.ts | 18 ++++- .../lib/test/composition/browser.test.ts | 61 ++++++++++++++- .../lib/test/kernel/diagnostics.test.ts | 19 +++++ .../lib/test/kernel/runtime.test.ts | 31 ++++++++ .../lib/test/services/render.test.ts | 3 + 8 files changed, 221 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 9257e4c3f..60e369886 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -20,7 +20,13 @@ import { } from '../adapters/prebid'; import { parseCacheFetchPolicyV1 } from '../core/config'; import { parseTrustedServerAuctionResponseV1 } from '../core/auction'; -import type { BrowserAuctionProjectionV1, CreativeBootV1 } from '../core/types'; +import type { + BootManifestV1, + BrowserAuctionProjectionV1, + CreativeBootV1, + DiagnosticsBootV1, +} from '../core/types'; +import { createRenderTrace, type RenderTraceRuntimeOwner } from '../core/trace'; import { parseBidRenderSourceV1, parseBrowserAuctionProjectionV1, @@ -55,6 +61,11 @@ import { } from '../integrations/prebid/module'; import { createPrebidStartup } from '../integrations/prebid/startup'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; +import { + createDiagnosticsBus, + type DiagnosticsBus, + type DiagnosticsObservation, +} from '../kernel/diagnostics'; import type { NavigationIdentityIssuerFactory, RenderAttemptScope, @@ -186,9 +197,8 @@ interface AcceptedBrowserBoot { readonly auctionProjection: object; readonly cachePolicy?: unknown; readonly creative: Readonly; - readonly manifest: { - readonly integrations: readonly { readonly id: string }[]; - }; + readonly diagnostics: Readonly; + readonly manifest: Readonly; } interface PreparedBrowserServices { @@ -277,6 +287,47 @@ export function createTestBrowserRuntimeComposition( const providedBindings = runtimeOptions.getBindings; let browserServices: Readonly | undefined; let creativeBoot: Readonly | undefined; + let diagnosticsBus: DiagnosticsBus | undefined; + let renderTrace: RenderTraceRuntimeOwner | undefined; + const consumeCoreObservation = (observation: DiagnosticsObservation): void => { + if ( + observation['kind'] !== 'render_attempt' || + typeof observation['slotId'] !== 'string' || + (observation['path'] !== 'auction' && observation['path'] !== 'ssat') || + typeof observation['rendered'] !== '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 { + renderTrace?.record({ + slotId: observation['slotId'], + path: observation['path'], + rendered: observation['rendered'], + ...(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({ @@ -573,6 +624,7 @@ export function createTestBrowserRuntimeComposition( const runtime = createRuntime({ ...runtimeOptions, getBindings, + getDiagnosticsForPublish: diagnosticsForPublish, kernel: { addAdUnits: addProgrammaticAdUnits, diagnostics: runtimeOptions.kernel.diagnostics, @@ -590,6 +642,22 @@ export function createTestBrowserRuntimeComposition( parseProjection ); if (!initialProjection) throw new Error('Accepted boot projection is unavailable'); + const preparedRenderTrace = createRenderTrace({ + onSubscriberError: (error) => log.warn('render diagnostics: subscriber failed', error), + }); + const preparedDiagnosticsBus = createDiagnosticsBus({ + manifest: boot.manifest, + onObservation: consumeCoreObservation, + onSubscriberError: (error) => log.warn('diagnostics bus: subscriber failed', error), + }); + renderTrace = preparedRenderTrace; + diagnosticsBus = preparedDiagnosticsBus; + context.onDispose(() => { + preparedDiagnosticsBus.dispose(); + preparedRenderTrace.dispose(); + if (diagnosticsBus === preparedDiagnosticsBus) diagnosticsBus = undefined; + if (renderTrace === preparedRenderTrace) renderTrace = undefined; + }); const reconciliation = typeof document === 'undefined' || typeof MutationObserver === 'undefined' ? undefined @@ -737,6 +805,7 @@ export function createTestBrowserRuntimeComposition( const source = parseBidRenderSourceV1(candidate, cachePolicy); return source ? Object.freeze(source) : undefined; }, + publishDiagnostics: preparedDiagnosticsBus.publish, reservations: reservationService, }); const batchCoordinator = createAuctionBatchService({ @@ -785,6 +854,7 @@ export function createTestBrowserRuntimeComposition( interfaces: Object.freeze({ adapters: composition.adapters, creative: creativeRuntime, + diagnostics: Object.freeze({ subscribe: preparedDiagnosticsBus.subscribe }), gpt: gptRuntime, prebid: prebidRuntime, ...services, diff --git a/crates/trusted-server-js/lib/src/kernel/diagnostics.ts b/crates/trusted-server-js/lib/src/kernel/diagnostics.ts index 281e1f2c7..11fd03f9b 100644 --- a/crates/trusted-server-js/lib/src/kernel/diagnostics.ts +++ b/crates/trusted-server-js/lib/src/kernel/diagnostics.ts @@ -16,6 +16,8 @@ export interface DiagnosticsScheduler { export interface DiagnosticsBusOptions { readonly manifest: Readonly; + /** Closure-private core observer; never included in the returned bus facade. */ + readonly onObservation?: (observation: DiagnosticsObservation) => void; readonly onOverflow?: (droppedObservations: number) => void; readonly onSubscriberError?: (error: unknown) => void; readonly pendingCapacity?: number; @@ -170,6 +172,11 @@ export function createDiagnosticsBus(options: DiagnosticsBusOptions): Diagnostic return Object.freeze({ publish: (observation: DiagnosticsObservation): boolean => { if (disposed || !recursivelyFrozenRecord(observation)) return false; + try { + options.onObservation?.(observation); + } catch { + // Core diagnostics consumption cannot affect correctness publication. + } const captured = Object.freeze([...subscriptions.values()]); if (captured.length === 0) return true; if (pending.length >= pendingCapacity) { diff --git a/crates/trusted-server-js/lib/src/kernel/runtime.ts b/crates/trusted-server-js/lib/src/kernel/runtime.ts index 869b0f30a..191aafe5e 100644 --- a/crates/trusted-server-js/lib/src/kernel/runtime.ts +++ b/crates/trusted-server-js/lib/src/kernel/runtime.ts @@ -83,6 +83,8 @@ export interface RuntimeOptions { readonly boot?: unknown; readonly now?: () => number; readonly getBindings?: (id: string) => IntegrationBindings; + /** Resolve the complete frozen namespace after every diagnostics module activates. */ + readonly getDiagnosticsForPublish?: () => Readonly; readonly prepareOwner?: (context: RuntimeOwnerPreparationContext) => void; readonly activateOwner?: (context: RuntimeOwnerActivationContext) => void; readonly activateCore?: (context: CoreActivationContext) => void; @@ -284,6 +286,15 @@ class RuntimeOwner implements Runtime { } private kernelFields(): Readonly> { + const diagnostics = + this.options.getDiagnosticsForPublish?.() ?? this.options.kernel.diagnostics; + if ( + (typeof diagnostics !== 'object' && typeof diagnostics !== 'function') || + diagnostics === null || + !Object.isFrozen(diagnostics) + ) { + throw new Error('Published diagnostics namespace must be frozen'); + } const fields: Record = {}; Object.defineProperties(fields, { version: { enumerable: true, value: '1.0.0' }, @@ -296,7 +307,7 @@ class RuntimeOwner implements Runtime { _registerIntegration: { enumerable: true, value: () => false }, addAdUnits: { enumerable: true, value: this.options.kernel.addAdUnits }, requestAds: { enumerable: true, value: this.options.kernel.requestAds }, - diagnostics: { enumerable: true, value: this.options.kernel.diagnostics }, + diagnostics: { enumerable: true, value: diagnostics }, _internal: { enumerable: false, value: Object.freeze({ state: 'kernel', releaseId: EMBEDDED_RELEASE_ID }), diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index acce274d8..417bff92e 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -641,10 +641,13 @@ export interface RenderAttemptOptions { readonly scheduler?: RenderScheduler; } -export interface RenderAttemptDiagnosticsObservation { +export interface RenderAttemptDiagnosticsObservation extends Readonly> { readonly kind: 'render_attempt'; readonly attemptId: string; readonly slotId: string; + readonly path: 'auction' | 'ssat'; + readonly rendered: boolean; + readonly servedFrom?: 'inline' | 'pbs-cache'; readonly state: 'accepted' | 'no_bid' | 'failed' | 'cancelled'; readonly outcome: RenderOutcome; } @@ -1199,8 +1202,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp let prepareRenderSource: (candidate: unknown) => ReservationRenderSource | undefined; let reservations: ReservationService; let publishDiagnostics: - | ((observation: RenderAttemptDiagnosticsObservation) => unknown) - | undefined; + ((observation: RenderAttemptDiagnosticsObservation) => unknown) | undefined; let consumeClaimMethod: ReservationService['consumeClaim']; let ownerIsCurrentMethod: RenderAttemptScope['isCurrent']; let ownerDisposeMethod: RenderAttemptScope['dispose']; @@ -1533,6 +1535,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp const settle = (terminal: RenderOutcome, disposeOwner: boolean): boolean => { if (outcome !== undefined) return false; + const terminalRenderSource = admittedRenderSource; outcome = terminal; state = terminalState(terminal); arrayPush(history, state); @@ -1555,10 +1558,19 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp } } if (publishDiagnostics) { + const servedFrom = + terminal.outcome === 'accepted' && terminalRenderSource?.type === 'cache' + ? ('pbs-cache' as const) + : terminal.outcome === 'accepted' + ? ('inline' as const) + : undefined; const observation = frozen({ kind: 'render_attempt', attemptId: id, slotId: slot, + path: history.includes('waiting_for_gam_and_claim') ? 'ssat' : 'auction', + rendered: terminal.outcome === 'accepted', + ...(servedFrom === undefined ? {} : { servedFrom }), state: terminal.outcome, outcome: terminal, }); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 16b530e4d..c1e846e34 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -574,7 +574,16 @@ describe('browser composition', () => { composition.runtime.registerIntegration({ id: 'test', release: 'a'.repeat(64), - prepare: ({ onDispose }: { onDispose(callback: () => void): void }) => { + prepare: ({ + interfaces, + onDispose, + }: { + interfaces: Readonly>; + onDispose(callback: () => void): void; + }) => { + expect(Reflect.ownKeys(interfaces['diagnostics'] as object)).toEqual(['subscribe']); + expect(interfaces['diagnostics']).not.toHaveProperty('publish'); + expect(interfaces['diagnostics']).not.toHaveProperty('dispose'); onDispose(() => order.push('dispose-module')); return { activate: () => order.push('module') }; }, @@ -583,6 +592,26 @@ describe('browser composition', () => { 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([ @@ -602,6 +631,8 @@ describe('browser composition', () => { ); 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 slot listeners before post-commit GPT startup and disposes both listeners', async () => { @@ -1792,6 +1823,10 @@ describe('browser composition', () => { 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', { @@ -1973,6 +2008,30 @@ describe('browser composition', () => { { 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, + servedFrom: 'inline', + count: 1, + }) + ); + expect(renderTrace?.history()).toHaveLength(1); + expect(Object.isFrozen(renderTrace?.history()[0])).toBe(true); + expect(target).not.toHaveProperty('renders'); + expect(target).not.toHaveProperty('renderLog'); + expect(target).not.toHaveProperty('renderSeq'); expect(requestBodies[0]).toEqual({ adUnits: [programmatic], config: { page: 'context' }, diff --git a/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts index 0b02a85f8..db4dcbb90 100644 --- a/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts @@ -151,4 +151,23 @@ describe('kernel diagnostics bus', () => { expect(read).not.toHaveBeenCalled(); bus.dispose(); }); + + it('commits to the private core observer before asynchronous module delivery', () => { + vi.useFakeTimers(); + const order: string[] = []; + const bus = createDiagnosticsBus({ + manifest: manifest(['observer']), + onObservation: () => { + order.push('core'); + throw new Error('fictional core observer failure'); + }, + }); + bus.subscribe('observer', () => order.push('module')); + + expect(bus.publish(observation(1))).toBe(true); + expect(order).toEqual(['core']); + vi.runOnlyPendingTimers(); + expect(order).toEqual(['core', 'module']); + bus.dispose(); + }); }); diff --git a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts index b1297ffce..9af56233e 100644 --- a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -126,6 +126,37 @@ describe('Runtime bootstrap owner', () => { ).toBe(false); }); + 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; diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index 6caccf0b8..028d0b47c 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -4575,6 +4575,9 @@ describe('RenderAttempt diagnostics producer', () => { kind: 'render_attempt', attemptId: renderAttempt.id, slotId: renderAttempt.slot, + path: 'auction', + rendered: true, + servedFrom: 'inline', state: 'accepted', outcome: { outcome: 'accepted' }, }); From 4da3000f3829a401144425046c8e1ef4748f8dda Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:44:37 -0700 Subject: [PATCH 120/194] Bound GPT diagnostics notifications --- .../src/integrations/gpt_diagnostics/api.ts | 154 +++++++++++++----- .../integrations/gpt_diagnostics/api.test.ts | 86 +++++++++- 2 files changed, 192 insertions(+), 48 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts index ab869d322..ec69d4eb0 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts @@ -1,4 +1,5 @@ import type { GptDiagnosticsApi, GptDiagnosticsExportV1 } from '../../core/types'; +import { DiagnosticsSubscriberLimitError } from '../../core/trace'; import type { GptDiagnosticsBindingManager } from './binding'; import type { GptDiagnosticsStoreSnapshot } from './store'; @@ -27,10 +28,21 @@ interface ApiOptions { window?: ApiWindow | undefined; document?: Document | undefined; now?: (() => Date) | undefined; - schedule?: ((callback: () => void) => void) | undefined; + schedule?: ((callback: () => void) => () => void) | undefined; } type ApiListener = (snapshot: GptDiagnosticsExportV1) => void; +const MAX_API_SUBSCRIBERS = 32; + +interface PendingNotification { + readonly snapshot: GptDiagnosticsExportV1; + readonly subscriberIds: readonly number[]; +} + +function scheduleTask(callback: () => void): () => void { + const handle = globalThis.setTimeout(callback, 0); + return () => globalThis.clearTimeout(handle); +} /** Owns the public read-only diagnostics API and its source subscriptions. */ export class GptDiagnosticsApiController { @@ -42,11 +54,13 @@ export class GptDiagnosticsApiController { private readonly window: ApiWindow; private readonly document: Document; private readonly now: () => Date; - private readonly schedule: (callback: () => void) => void; - private readonly listeners = new Set(); + private readonly schedule: (callback: () => void) => () => void; + private readonly listeners = new Map(); private readonly unsubscribeStore: () => void; private readonly unsubscribeBindings: () => void; - private notificationScheduled = false; + private pending: PendingNotification | undefined; + private cancelScheduled: (() => void) | undefined; + private nextSubscriberId = 0; private destroyed = false; constructor( @@ -61,47 +75,65 @@ export class GptDiagnosticsApiController { this.window = options.window ?? (window as unknown as ApiWindow); this.document = options.document ?? document; this.now = options.now ?? (() => new Date()); - this.schedule = options.schedule ?? ((callback) => queueMicrotask(callback)); + this.schedule = options.schedule ?? scheduleTask; this.unsubscribeStore = this.store.subscribe(() => this.scheduleNotification()); this.unsubscribeBindings = this.bindings.subscribe(() => this.scheduleNotification()); - this.api = { + this.api = Object.freeze({ snapshot: () => this.snapshot(), export: () => this.download(), - subscribe: (listener) => this.subscribe(listener), + subscribe: (listener: ApiListener) => this.subscribe(listener), show: () => this.presentation.show(), hide: () => this.presentation.hide(), - }; + }); } snapshot(): GptDiagnosticsExportV1 { const store = this.store.snapshot(); - return { + const slots = Object.freeze( + store.slots.map((slot) => + Object.freeze({ + runtimeSlotNumber: slot.runtimeSlotNumber, + slotElementId: slot.slotElementId, + adUnitPath: slot.adUnitPath, + binding: Object.freeze({ ...this.bindings.exportBinding(slot.runtimeSlotNumber) }), + currentVisibilityPercentage: slot.currentVisibilityPercentage, + maximumVisibilityPercentage: slot.maximumVisibilityPercentage, + requests: Object.freeze( + slot.requests.map((cycle) => + Object.freeze({ + ...cycle, + durations: Object.freeze({ ...cycle.durations }), + size: cycle.size ? Object.freeze([...cycle.size]) : undefined, + }) + ) + ), + }) + ) + ); + const callbackIssues = Object.freeze( + store.callbackIssues.map((issue) => Object.freeze({ ...issue })) + ); + const coverage = Object.freeze( + Object.fromEntries( + Object.entries(store.coverage).map(([kind, counters]) => [ + kind, + Object.freeze({ ...counters }), + ]) + ) + ) as GptDiagnosticsExportV1['coverage']; + return Object.freeze({ version: 1, capturedAt: this.now().toISOString(), - page: { + page: Object.freeze({ origin: this.window.location.origin, pathname: this.window.location.pathname, - }, - slots: store.slots.map((slot) => ({ - runtimeSlotNumber: slot.runtimeSlotNumber, - slotElementId: slot.slotElementId, - adUnitPath: slot.adUnitPath, - binding: this.bindings.exportBinding(slot.runtimeSlotNumber), - currentVisibilityPercentage: slot.currentVisibilityPercentage, - maximumVisibilityPercentage: slot.maximumVisibilityPercentage, - requests: slot.requests.map((cycle) => ({ - ...cycle, - durations: { ...cycle.durations }, - size: cycle.size ? [...cycle.size] : undefined, - })), - })), - callbackIssues: store.callbackIssues.map((issue) => ({ ...issue })), - coverage: Object.fromEntries( - Object.entries(store.coverage).map(([kind, counters]) => [kind, { ...counters }]) - ) as GptDiagnosticsExportV1['coverage'], - metadata: { ...store.metadata }, - }; + }), + slots, + callbackIssues, + coverage, + metadata: Object.freeze({ ...store.metadata }), + }) as GptDiagnosticsExportV1; } destroy(): void { @@ -109,13 +141,30 @@ export class GptDiagnosticsApiController { this.destroyed = true; this.unsubscribeStore(); this.unsubscribeBindings(); + try { + this.cancelScheduled?.(); + } catch { + // The destroyed latch suppresses a hostile late scheduler callback. + } + this.cancelScheduled = undefined; + this.pending = undefined; this.listeners.clear(); } private subscribe(listener: ApiListener): () => void { + if (typeof listener !== 'function') throw new TypeError('Diagnostics listener must be callable'); if (this.destroyed) return () => undefined; - this.listeners.add(listener); - return () => this.listeners.delete(listener); + if (this.listeners.size >= MAX_API_SUBSCRIBERS) { + throw new DiagnosticsSubscriberLimitError('gpt'); + } + const id = (this.nextSubscriberId += 1); + this.listeners.set(id, listener); + let active = true; + return () => { + if (!active) return; + active = false; + this.listeners.delete(id); + }; } private download(): void { @@ -139,19 +188,34 @@ export class GptDiagnosticsApiController { } private scheduleNotification(): void { - if (this.destroyed || this.notificationScheduled) return; - this.notificationScheduled = true; - this.schedule(() => { - this.notificationScheduled = false; - if (this.destroyed) return; - - for (const listener of this.listeners) { - try { - listener(this.snapshot()); - } catch { - // One API subscriber must not block the rest. - } - } + if (this.destroyed || this.listeners.size === 0) return; + const pending = Object.freeze({ + snapshot: this.snapshot(), + subscriberIds: Object.freeze([...this.listeners.keys()]), }); + this.pending = pending; + if (this.cancelScheduled) return; + try { + const cancel = this.schedule(() => { + this.cancelScheduled = undefined; + const notification = this.pending; + this.pending = undefined; + if (this.destroyed || !notification) return; + for (const id of notification.subscriberIds) { + const listener = this.listeners.get(id); + if (!listener) continue; + try { + listener(notification.snapshot); + } catch { + // One API subscriber must not block the rest. + } + } + }); + if (typeof cancel !== 'function') throw new TypeError('Invalid diagnostics scheduler'); + if (!this.destroyed && this.pending) this.cancelScheduled = cancel; + } catch { + this.cancelScheduled = undefined; + this.pending = undefined; + } } } 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 b3350de51..70f43243f 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'; @@ -37,6 +38,16 @@ function readBlob(blob: Blob): Promise { }); } +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'); @@ -98,6 +109,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', () => { @@ -113,7 +129,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(() => { @@ -138,6 +154,66 @@ describe('GptDiagnosticsApiController', () => { expect(listener).toHaveBeenCalledTimes(1); }); + it('captures subscriber membership per commit and coalesces to the latest snapshot', () => { + const scheduled: Array<() => void> = []; + const store = new GptDiagnosticsStore({ now: () => 1, schedule: (callback) => callback() }); + const bindings = new FakeBindings(); + const controller = new GptDiagnosticsApiController( + store, + bindings, + { show: vi.fn(), hide: vi.fn() }, + { + now: () => new Date('2026-07-28T00:00:00.000Z'), + schedule: scheduleInto(scheduled), + } + ); + 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('validates callability before enforcing the shared 32-subscriber cap', () => { + const controller = new GptDiagnosticsApiController( + new GptDiagnosticsStore({ now: () => 1 }), + new FakeBindings(), + { show: vi.fn(), hide: vi.fn() } + ); + const releases = Array.from({ length: 32 }, () => + controller.api.subscribe(() => undefined) + ); + + expect(() => controller.api.subscribe(null as never)).toThrow(TypeError); + expect(() => controller.api.subscribe(() => undefined)).toThrow( + DiagnosticsSubscriberLimitError + ); + expect(() => controller.api.subscribe(() => undefined)).toThrow( + expect.objectContaining({ code: 'subscriber_capacity', surface: 'gpt' }) + ); + releases[0]?.(); + releases[0]?.(); + expect(controller.api.subscribe(() => undefined)).toEqual(expect.any(Function)); + }); + it('delegates show and hide without mutating diagnostics data', () => { const store = new GptDiagnosticsStore({ now: () => 1 }); const presentation = { show: vi.fn(), hide: vi.fn() }; @@ -206,13 +282,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([]); From 40c469691b5d62b720aecd1826cc73a1923277ca Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:46:29 -0700 Subject: [PATCH 121/194] Harden publisher GPT call provenance --- .../lib/src/adapters/googletag.ts | 208 +++++++++++++----- .../lib/test/adapters/googletag.test.ts | 202 +++++++++++++++++ .../lib/test/services/targeting.test.ts | 33 +++ 3 files changed, 391 insertions(+), 52 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index d4c00efd6..dc3336af4 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -89,6 +89,12 @@ export interface GoogletagTargetingObservation { 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?: ( @@ -97,12 +103,16 @@ export interface GoogletagPublisherCallObserver { readonly destroySlots?: (call: Readonly) => void; readonly display?: ( call: Readonly - ) => Readonly<{ action: 'forward' }> | Readonly<{ action: 'suppress' }>; - readonly refresh?: ( - call: Readonly ) => - | Readonly<{ action: 'forward' }> - | Readonly<{ action: 'replace'; slots: readonly object[] }> + | 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: 'suppress' }>; } @@ -394,10 +404,15 @@ function createFacade( isOperationCurrent: () => boolean, isBindingCurrent: () => boolean, initialLoadDisabled: (service: object) => boolean, - targetingWrites: WeakMap, targetingObservations: WeakMap, bindingToken: object, - markFirstDisplay: () => void + markFirstDisplay: () => void, + invokeFacadeCall: ( + callable: (...arguments_: unknown[]) => unknown, + receiver: unknown, + arguments_: readonly unknown[] + ) => unknown, + consumeFacadeCall: (callable: (...arguments_: unknown[]) => unknown) => boolean ): Readonly { const member = (external: object, key: PropertyKey): ((...args: unknown[]) => unknown) => { if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); @@ -412,7 +427,7 @@ function createFacade( 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 = Reflect.apply(callable, external, argumentsList); + const result = invokeFacadeCall(callable, external, argumentsList); if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); return result; }; @@ -423,16 +438,6 @@ function createFacade( return result; }; const service = (): object => asObject(call(binding.binding, 'pubads', [])); - const withTargetingWrite = (slot: object, callback: () => unknown): unknown => { - const depth = weakMapValue(targetingWrites, slot) ?? 0; - setWeakMapValue(targetingWrites, slot, depth + 1); - try { - return callback(); - } finally { - if (depth === 0) deleteWeakMapValue(targetingWrites, slot); - else setWeakMapValue(targetingWrites, slot, depth); - } - }; const replaceObservedMethod = ( slot: object, key: 'clearTargeting' | 'setTargeting', @@ -443,13 +448,14 @@ function createFacade( let descriptor: PropertyDescriptor | undefined; let defineAttempted = false; const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { - if ((weakMapValue(targetingWrites, slot) ?? 0) === 0) { - 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. - } + 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_); }; @@ -507,13 +513,13 @@ function createFacade( return Object.freeze({ bindingToken: (): object => bindingToken, clearTargeting: (slot: object, key?: string): unknown => - withTargetingWrite(slot, () => call(slot, 'clearTargeting', key === undefined ? [] : [key])), + call(slot, 'clearTargeting', key === undefined ? [] : [key]), 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 = Reflect.apply(display, binding.binding, [slot]); + const result = invokeFacadeCall(display, binding.binding, [slot]); if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); return result; }, @@ -645,9 +651,7 @@ function createFacade( }); }, setTargeting: (slot: object, key: string, value: string | readonly string[]): unknown => - withTargetingWrite(slot, () => - call(slot, 'setTargeting', [key, Array.isArray(value) ? [...value] : value]) - ), + call(slot, 'setTargeting', [key, Array.isArray(value) ? [...value] : value]), slots: (): readonly object[] => { const currentSlots = call(service(), 'getSlots', []); if ( @@ -822,15 +826,36 @@ export function createBrowserGoogletagAdapter( const live = new Set>(); const effects = new Set<() => void>(); let armedBindings = new WeakSet(); - const targetingWrites = new WeakMap(); const targetingObservations = new WeakMap(); + const facadeCalls = new WeakMap<(...arguments_: unknown[]) => unknown, number>(); const bindingTokens = new WeakMap(); const initialLoadReleases = new Map void>(); const initialLoadOwner = Object.freeze({}); let pendingReservations = 0; let disposed = false; let firstDisplayObserved = false; - let trustedCallDepth = 0; + + 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; @@ -1519,10 +1544,11 @@ export function createBrowserGoogletagAdapter( const tracker = ensureInitialLoadTracking(binding, service); return tracker?.disabled === true; }, - targetingWrites, targetingObservations, bindingToken, - markFirstDisplay + markFirstDisplay, + invokeFacadeCall, + consumeFacadeCall ); try { if (disposed) { @@ -1562,13 +1588,7 @@ export function createBrowserGoogletagAdapter( return; } try { - trustedCallDepth += 1; - let value: unknown; - try { - value = operation.command(facade); - } finally { - trustedCallDepth -= 1; - } + const value = operation.command(facade); if (operation.settled) return; if (disposed) { fail(operation, 'operation_disposed'); @@ -1908,6 +1928,66 @@ export function createBrowserGoogletagAdapter( !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) || @@ -1942,7 +2022,10 @@ export function createBrowserGoogletagAdapter( if (typeof original !== 'function') return; const callable = original as (...arguments_: unknown[]) => unknown; const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { - if (trustedCallDepth > 0 || !stillCurrent()) { + if (consumeFacadeCall(wrapper)) { + return Reflect.apply(callable, this, arguments_); + } + if (!safelyCurrent()) { return Reflect.apply(callable, this, arguments_); } return mediate(callable, this, arguments_); @@ -1979,17 +2062,24 @@ export function createBrowserGoogletagAdapter( }); install(currentBindingObject, 'display', (original, receiver, arguments_) => { if (displayObserver && arguments_.length === 1) { + let decision: ReturnType>; try { - const decision = displayObserver( + decision = displayObserver( Object.freeze({ target: arguments_[0], initialLoadDisabled: tracker?.disabled === true, }) ); - if (decision?.action === 'suppress') return undefined; } 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_); }); @@ -1998,20 +2088,34 @@ export function createBrowserGoogletagAdapter( const requested = arguments_[0] === undefined ? undefined : objectSlots(arguments_[0]); const effective = requested ?? (arguments_[0] === undefined ? allSlots() : undefined); if (effective) { + let decision: ReturnType>; try { - const decision = refreshObserver( + decision = refreshObserver( Object.freeze({ requestedSlots: requested, slots: effective }) ); - if (decision?.action === 'suppress') return undefined; - if (decision?.action === 'replace') { - const replacement = objectSlots(decision.slots); - if (replacement) { - return Reflect.apply(original, receiver, [replacement, ...arguments_.slice(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_); } + return callWithAdmission(original, receiver, arguments_, admission); } } return Reflect.apply(original, receiver, arguments_); diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index 82cdd3edd..90fefe4e0 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -2010,6 +2010,208 @@ describe('browser googletag adapter readiness', () => { }); }); + it('observes publisher GPT calls reentered by one facade-driven native display', async () => { + const ready = createReadyGoogletag(); + const slot = Object.freeze({ id: 'nested-publisher-slot' }); + ready.pubads.getSlots.mockReturnValue([slot]); + ready.googletag.defineSlot.mockReturnValue(slot); + ready.googletag.destroySlots.mockReturnValue(true); + ready.display.mockImplementation(() => { + ready.pubads.refresh([slot], { changeCorrelator: true }); + ready.googletag.defineSlot('/publisher', [300, 250], 'nested-slot'); + ready.googletag.destroySlots([slot]); + }); + const observer = { + defineSlot: vi.fn(() => Object.freeze({ action: 'forward' as const })), + destroySlots: vi.fn(), + display: vi.fn(() => Object.freeze({ action: 'forward' as const })), + refresh: vi.fn(() => Object.freeze({ action: 'forward' as const })), + }; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls(observer); + + await expect(adapter.run((gpt) => gpt.display('trusted-slot')).result).resolves.toBeUndefined(); + + expect(observer.display).not.toHaveBeenCalled(); + expect(observer.refresh).toHaveBeenCalledExactlyOnceWith({ + requestedSlots: [slot], + slots: [slot], + }); + expect(observer.defineSlot).toHaveBeenCalledExactlyOnceWith({ + adUnitPath: '/publisher', + elementId: 'nested-slot', + initialLoadDisabled: false, + sizes: [300, 250], + }); + expect(observer.destroySlots).toHaveBeenCalledExactlyOnceWith({ slots: [slot] }); + }); + + it('observes a publisher wrapper call made inside a TS command but outside a facade invocation', async () => { + const ready = createReadyGoogletag(); + const observer = { + defineSlot: vi.fn(() => Object.freeze({ action: 'forward' as const })), + display: vi.fn(() => Object.freeze({ action: 'forward' as const })), + }; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls(observer); + + await expect( + adapter.run((gpt) => { + ready.googletag.defineSlot('/publisher', [300, 250], 'publisher-inside-command'); + gpt.display('trusted-slot'); + }).result + ).resolves.toBeUndefined(); + + expect(observer.defineSlot).toHaveBeenCalledExactlyOnceWith({ + adUnitPath: '/publisher', + elementId: 'publisher-inside-command', + initialLoadDisabled: false, + sizes: [300, 250], + }); + expect(observer.display).not.toHaveBeenCalled(); + }); + + it('commits publisher display and refresh admissions only after exact native returns', () => { + const ready = createReadyGoogletag(); + const slot = Object.freeze({ id: 'publisher-slot' }); + const receiver = Object.freeze({ publisher: true }); + const refreshOptions = Object.freeze({ changeCorrelator: false, publisher: 'exact' }); + const order: string[] = []; + const displayAdmission = Object.freeze({ + commit: vi.fn(() => order.push('commit:display')), + rollback: vi.fn(), + }); + const refreshAdmission = Object.freeze({ + commit: vi.fn(() => order.push('commit:refresh')), + rollback: vi.fn(), + }); + const nativeDisplay = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:display'); + return Object.freeze({ arguments_, receiver: this }); + }); + const nativeRefresh = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:refresh'); + return Object.freeze({ arguments_, receiver: this }); + }); + ready.googletag.display = nativeDisplay; + ready.pubads.refresh = nativeRefresh; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls({ + display: () => Object.freeze({ action: 'forward' as const, admission: displayAdmission }), + refresh: () => Object.freeze({ action: 'forward' as const, admission: refreshAdmission }), + }); + + const display = ready.googletag.display as (...arguments_: unknown[]) => unknown; + expect(Reflect.apply(display, receiver, ['slot'])).toEqual({ + arguments_: ['slot'], + receiver, + }); + const refresh = ready.pubads.refresh as (...arguments_: unknown[]) => unknown; + expect(Reflect.apply(refresh, receiver, [[slot], refreshOptions])).toEqual({ + arguments_: [[slot], refreshOptions], + receiver, + }); + + expect(order).toEqual(['native:display', 'commit:display', 'native:refresh', 'commit:refresh']); + expect(displayAdmission.rollback).not.toHaveBeenCalled(); + expect(refreshAdmission.rollback).not.toHaveBeenCalled(); + }); + + it('rolls back each unconsumed publisher admission on native throw and rethrows the exact error', () => { + const ready = createReadyGoogletag(); + const displayError = new Error('exact display failure'); + const refreshError = new Error('exact refresh failure'); + const displayAdmissions = [0, 1].map(() => + Object.freeze({ commit: vi.fn(), rollback: vi.fn() }) + ); + const refreshAdmission = Object.freeze({ commit: vi.fn(), rollback: vi.fn() }); + ready.googletag.display = vi.fn(() => { + throw displayError; + }); + ready.pubads.refresh = vi.fn(() => { + throw refreshError; + }); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + let displayAttempt = 0; + adapter.observePublisherCalls({ + display: () => + Object.freeze({ + action: 'forward' as const, + admission: displayAdmissions[displayAttempt++]!, + }), + refresh: () => Object.freeze({ action: 'forward' as const, admission: refreshAdmission }), + }); + + const display = ready.googletag.display as (...arguments_: unknown[]) => unknown; + expect(() => display('slot')).toThrow(displayError); + expect(() => display('slot')).toThrow(displayError); + const refresh = ready.pubads.refresh as (...arguments_: unknown[]) => unknown; + expect(() => refresh(undefined, { changeCorrelator: true })).toThrow(refreshError); + + for (const admission of displayAdmissions) { + expect(admission.rollback).toHaveBeenCalledOnce(); + expect(admission.commit).not.toHaveBeenCalled(); + } + expect(refreshAdmission.rollback).toHaveBeenCalledOnce(); + expect(refreshAdmission.commit).not.toHaveBeenCalled(); + }); + + it.each(['pubads', 'target_getter'] as const)( + 'fails open to captured publisher natives when %s identity probing throws', + (failure) => { + const ready = createReadyGoogletag(); + const target: { googletag?: unknown } = { googletag: ready.googletag }; + const slot = Object.freeze({ id: 'publisher-slot' }); + const nativeDefine = vi.fn(() => 'defined'); + const nativeDisplay = vi.fn(() => 'displayed'); + const nativeRefresh = vi.fn(() => 'refreshed'); + const nativeDestroy = vi.fn(() => true); + ready.googletag.defineSlot = nativeDefine; + ready.googletag.display = nativeDisplay; + ready.googletag.destroySlots = nativeDestroy; + ready.pubads.refresh = nativeRefresh; + ready.pubads.getSlots.mockReturnValue([slot]); + const adapter = createBrowserGoogletagAdapter(target); + const observer = { + defineSlot: vi.fn(() => Object.freeze({ action: 'forward' as const })), + destroySlots: vi.fn(), + display: vi.fn(() => Object.freeze({ action: 'forward' as const })), + refresh: vi.fn(() => Object.freeze({ action: 'forward' as const })), + }; + adapter.observePublisherCalls(observer); + const define = ready.googletag.defineSlot as (...arguments_: unknown[]) => unknown; + const display = ready.googletag.display as (...arguments_: unknown[]) => unknown; + const refresh = ready.pubads.refresh as (...arguments_: unknown[]) => unknown; + const destroy = ready.googletag.destroySlots as (...arguments_: unknown[]) => unknown; + const identityError = new Error(`throwing ${failure}`); + if (failure === 'pubads') { + ready.googletag.pubads.mockImplementation(() => { + throw identityError; + }); + } else { + Object.defineProperty(target, 'googletag', { + configurable: true, + get: () => { + throw identityError; + }, + }); + } + + expect(define('/publisher', [300, 250], 'slot')).toBe('defined'); + expect(display('slot')).toBe('displayed'); + expect(refresh([slot], { changeCorrelator: false })).toBe('refreshed'); + expect(destroy([slot])).toBe(true); + expect(nativeDefine).toHaveBeenCalledOnce(); + expect(nativeDisplay).toHaveBeenCalledOnce(); + expect(nativeRefresh).toHaveBeenCalledOnce(); + expect(nativeDestroy).toHaveBeenCalledOnce(); + expect(observer.defineSlot).not.toHaveBeenCalled(); + expect(observer.display).not.toHaveBeenCalled(); + expect(observer.refresh).not.toHaveBeenCalled(); + expect(observer.destroySlots).not.toHaveBeenCalled(); + } + ); + it('mediates only explicit publisher decisions and preserves receiver, arguments, return, throw, and order', () => { const ready = createReadyGoogletag({ initialLoadDisabled: true }); const handoffSlot = Object.freeze({ id: 'handoff' }); diff --git a/crates/trusted-server-js/lib/test/services/targeting.test.ts b/crates/trusted-server-js/lib/test/services/targeting.test.ts index c637d41bc..022abf5d3 100644 --- a/crates/trusted-server-js/lib/test/services/targeting.test.ts +++ b/crates/trusted-server-js/lib/test/services/targeting.test.ts @@ -206,6 +206,39 @@ describe('owner-aware targeting journal', () => { 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) => { From fac4c500c81be544d61f87d9a5f830dc266b508d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:47:10 -0700 Subject: [PATCH 122/194] Format GPT diagnostics notifications --- .../lib/src/integrations/gpt_diagnostics/api.ts | 3 ++- .../lib/test/integrations/gpt_diagnostics/api.test.ts | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts index ec69d4eb0..5b1bbcb3c 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts @@ -152,7 +152,8 @@ export class GptDiagnosticsApiController { } private subscribe(listener: ApiListener): () => void { - if (typeof listener !== 'function') throw new TypeError('Diagnostics listener must be callable'); + if (typeof listener !== 'function') + throw new TypeError('Diagnostics listener must be callable'); if (this.destroyed) return () => undefined; if (this.listeners.size >= MAX_API_SUBSCRIBERS) { throw new DiagnosticsSubscriberLimitError('gpt'); 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 70f43243f..b6d90f4b4 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 @@ -198,9 +198,7 @@ describe('GptDiagnosticsApiController', () => { new FakeBindings(), { show: vi.fn(), hide: vi.fn() } ); - const releases = Array.from({ length: 32 }, () => - controller.api.subscribe(() => undefined) - ); + const releases = Array.from({ length: 32 }, () => controller.api.subscribe(() => undefined)); expect(() => controller.api.subscribe(null as never)).toThrow(TypeError); expect(() => controller.api.subscribe(() => undefined)).toThrow( From 899e08e54a8eacee77cbb4edb3952ac6772fbb01 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:55:24 -0700 Subject: [PATCH 123/194] Add bounded GPT diagnostics fact capture --- .../lib/src/adapters/googletag.ts | 125 ++++++++++- .../src/integrations/gpt_diagnostics/facts.ts | 209 ++++++++++++++++++ .../lib/test/adapters/googletag.test.ts | 64 ++++++ .../gpt_diagnostics/facts.test.ts | 104 +++++++++ 4 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/facts.test.ts diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index dc3336af4..4585a25eb 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -183,6 +183,7 @@ export interface GoogletagOperation { /** Narrow GPT boundary consumed by kernel sessions and services. */ export interface GoogletagAdapter { bindingStatus(): GoogletagBindingStatus; + observeDiagnostics?(observer: GoogletagDiagnosticsObserver): (() => void) | undefined; observePublisherCalls(observer: GoogletagPublisherCallObserver): () => void; run( command: (googletag: Readonly) => T, @@ -192,6 +193,26 @@ export interface GoogletagAdapter { dispose(): void; } +export type GoogletagDiagnosticsEventName = + | 'slotRequested' + | 'slotResponseReceived' + | 'slotRenderEnded' + | 'slotOnload' + | 'impressionViewable' + | 'slotVisibilityChanged'; + +export interface GoogletagDiagnosticsFact { + readonly kind: GoogletagDiagnosticsEventName; + readonly slot: object; + readonly isEmpty?: boolean; + readonly size?: readonly [number, number]; + readonly isBackfill?: boolean; + readonly slotContentChanged?: boolean; + readonly inViewPercentage?: number; +} + +export type GoogletagDiagnosticsObserver = (fact: Readonly) => void; + /** Browser surface owned by the concrete GPT adapter. */ export interface GoogletagGlobalTarget { googletag?: unknown; @@ -412,7 +433,8 @@ function createFacade( receiver: unknown, arguments_: readonly unknown[] ) => unknown, - consumeFacadeCall: (callable: (...arguments_: unknown[]) => unknown) => boolean + consumeFacadeCall: (callable: (...arguments_: unknown[]) => unknown) => boolean, + publishDiagnostics: (eventType: string, event: unknown) => void ): Readonly { const member = (external: object, key: PropertyKey): ((...args: unknown[]) => unknown) => { if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); @@ -674,6 +696,7 @@ function createFacade( } catch { // Publisher and service callbacks cannot escape the GPT boundary. } + publishDiagnostics(eventType, event); }; let attempted = false; const rollback = (): void => { @@ -831,10 +854,83 @@ export function createBrowserGoogletagAdapter( const bindingTokens = new WeakMap(); const initialLoadReleases = new Map void>(); const initialLoadOwner = Object.freeze({}); + let diagnosticsObserver: GoogletagDiagnosticsObserver | undefined; let pendingReservations = 0; let disposed = false; let firstDisplayObserved = false; + const diagnosticFact = ( + eventType: string, + event: unknown + ): 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 base = { kind: eventType, slot: slot as object }; + 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]); + } + } + return Object.freeze({ + ...base, + kind: eventType, + ...(typeof isEmpty === 'boolean' ? { isEmpty } : {}), + ...(size ? { size } : {}), + ...(typeof isBackfill === 'boolean' ? { isBackfill } : {}), + ...(typeof slotContentChanged === 'boolean' ? { slotContentChanged } : {}), + }); + } + default: + return undefined; + } + } catch { + return undefined; + } + }; + + const publishDiagnostics = (eventType: string, event: unknown): void => { + const observer = diagnosticsObserver; + if (!observer || disposed) return; + const fact = diagnosticFact(eventType, event); + if (!fact) return; + try { + observer(fact); + } catch { + // Diagnostics observation cannot escape the GPT correctness callback. + } + }; + const invokeFacadeCall = ( callable: (...arguments_: unknown[]) => unknown, receiver: unknown, @@ -1548,7 +1644,8 @@ export function createBrowserGoogletagAdapter( bindingToken, markFirstDisplay, invokeFacadeCall, - consumeFacadeCall + consumeFacadeCall, + publishDiagnostics ); try { if (disposed) { @@ -2156,8 +2253,32 @@ export function createBrowserGoogletagAdapter( 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, + observeDiagnostics, observePublisherCalls, run, notifyReady, diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts new file mode 100644 index 000000000..623ac9ba5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts @@ -0,0 +1,209 @@ +import type { + GoogletagAdapter, + GoogletagDiagnosticsFact, + GoogletagDiagnosticsObserver, +} from '../../adapters/googletag'; + +const MAX_BUFFERED_FACTS = 512; +const DIAGNOSTICS_ONLY_EVENTS = Object.freeze([ + 'slotResponseReceived', + 'slotOnload', + 'impressionViewable', + 'slotVisibilityChanged', +] as const); + +export interface GptDiagnosticsFactBufferOptions { + readonly onConsumerError?: (error: unknown) => void; + readonly onOverflow?: (droppedFacts: number) => void; +} + +export interface GptDiagnosticsFactBuffer { + readonly publish: (fact: Readonly) => boolean; + readonly activate: (consumer: GoogletagDiagnosticsObserver) => (() => void) | undefined; + readonly dispose: () => void; +} + +function validFact(fact: unknown): fact is Readonly { + if (typeof fact !== 'object' || fact === null || !Object.isFrozen(fact)) return false; + const kind = Object.getOwnPropertyDescriptor(fact, 'kind'); + const slot = Object.getOwnPropertyDescriptor(fact, 'slot'); + return ( + ((kind !== undefined && + 'value' in kind && + DIAGNOSTICS_ONLY_EVENTS.includes(kind.value as (typeof DIAGNOSTICS_ONLY_EVENTS)[number])) || + kind?.value === 'slotRequested' || + kind?.value === 'slotRenderEnded') && + slot !== undefined && + 'value' in slot && + ((typeof slot.value === 'object' && slot.value !== null) || typeof slot.value === 'function') + ); +} + +/** Own the bounded handoff between early GPT callbacks and the diagnostics module. */ +export function createGptDiagnosticsFactBuffer( + options: GptDiagnosticsFactBufferOptions = {} +): GptDiagnosticsFactBuffer { + const pending: Readonly[] = []; + let consumer: GoogletagDiagnosticsObserver | undefined; + let consumerGeneration = 0; + let replaying = false; + let disposed = false; + let droppedFacts = 0; + + const reportConsumerError = (error: unknown): void => { + try { + options.onConsumerError?.(error); + } catch { + // Diagnostics error reporting cannot affect later fact delivery. + } + }; + const reportOverflow = (): void => { + droppedFacts += 1; + try { + options.onOverflow?.(droppedFacts); + } catch { + // Overflow reporting is diagnostics-only. + } + }; + const enqueue = (fact: Readonly): void => { + if (pending.length >= MAX_BUFFERED_FACTS) { + pending.shift(); + reportOverflow(); + } + pending.push(fact); + }; + const deliver = (fact: Readonly): void => { + const current = consumer; + if (!current) return; + try { + current(fact); + } catch (error) { + reportConsumerError(error); + } + }; + + return Object.freeze({ + publish: (fact: Readonly): boolean => { + if (disposed || !validFact(fact)) return false; + if (replaying || !consumer) enqueue(fact); + else deliver(fact); + return true; + }, + activate: (nextConsumer: GoogletagDiagnosticsObserver): (() => void) | undefined => { + if (disposed || consumer || typeof nextConsumer !== 'function') return undefined; + consumer = nextConsumer; + consumerGeneration += 1; + const generation = consumerGeneration; + replaying = true; + while (pending.length > 0 && consumer === nextConsumer && !disposed) { + const fact = pending.shift(); + if (fact) deliver(fact); + } + replaying = false; + if (!consumer || disposed) pending.length = 0; + let active = true; + return (): void => { + if (!active) return; + active = false; + if (consumerGeneration === generation && consumer === nextConsumer) consumer = undefined; + }; + }, + dispose: (): void => { + if (disposed) return; + disposed = true; + consumer = undefined; + replaying = false; + pending.length = 0; + }, + }); +} + +/** Connect the sole GPT adapter stream and only the four diagnostics-only listeners. */ +export function activateGptDiagnosticsFactCapture( + adapter: Pick, + buffer: Pick +): () => void { + let disposed = false; + let releases: readonly (() => void)[] = Object.freeze([]); + const observeDiagnostics = adapter.observeDiagnostics; + if (!observeDiagnostics) return () => undefined; + const releaseObserver = observeDiagnostics((fact) => { + try { + buffer.publish(fact); + } catch { + // Fact buffering cannot alter the already-completed GPT callback. + } + }); + if (!releaseObserver) return () => undefined; + + let operation: ReturnType | undefined; + try { + operation = adapter.run((gpt) => { + const installed: Array<() => void> = []; + try { + for (let index = 0; index < DIAGNOSTICS_ONLY_EVENTS.length; index += 1) { + const eventType = DIAGNOSTICS_ONLY_EVENTS[index]; + if (!eventType) continue; + installed[installed.length] = gpt.subscribe(eventType, () => undefined); + } + return Object.freeze(installed); + } catch (error) { + for (let index = installed.length - 1; index >= 0; index -= 1) { + try { + installed[index]?.(); + } catch { + // Continue rolling back the remaining listener ownership. + } + } + throw error; + } + }); + void operation.result.then( + (installed) => { + releases = installed as readonly (() => void)[]; + if (!disposed) return; + for (let index = releases.length - 1; index >= 0; index -= 1) { + try { + releases[index]?.(); + } catch { + // Late completion still releases every listener independently. + } + } + releases = Object.freeze([]); + }, + () => { + try { + releaseObserver(); + } catch { + // Failed activation retains no observer ownership. + } + } + ); + } catch { + releaseObserver(); + return () => undefined; + } + + return (): void => { + if (disposed) return; + disposed = true; + try { + operation?.dispose(); + } catch { + // Disposal continues through every independently owned resource. + } + for (let index = releases.length - 1; index >= 0; index -= 1) { + try { + releases[index]?.(); + } catch { + // One hostile listener release cannot retain the others. + } + } + releases = Object.freeze([]); + try { + releaseObserver(); + } catch { + // The adapter's disposed latch remains authoritative. + } + }; +} diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index 90fefe4e0..f60b509ea 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -1103,6 +1103,70 @@ describe('browser googletag adapter readiness', () => { expect(first.pubads.removeEventListener).toHaveBeenCalledTimes(1); }); + it('publishes frozen diagnostics facts after the sole adapter listener completes', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const order: string[] = []; + const facts: unknown[] = []; + const releaseDiagnostics = adapter.observeDiagnostics?.((fact) => { + order.push('diagnostics'); + facts.push(fact); + throw new Error('fictional diagnostics failure'); + }); + expect(releaseDiagnostics).toEqual(expect.any(Function)); + expect(ready.pubads.addEventListener).not.toHaveBeenCalled(); + + await adapter.run((gpt) => + gpt.subscribe('slotRenderEnded', () => { + order.push('correctness'); + }) + ).result; + expect(ready.pubads.addEventListener).toHaveBeenCalledTimes(1); + const slot = Object.freeze({ id: 'fictional-slot' }); + const emit = (event: unknown): void => { + for (const listener of ready.listeners.get('slotRenderEnded') ?? []) listener(event); + }; + expect(() => + emit({ + slot, + isEmpty: false, + size: [300, 250], + isBackfill: true, + slotContentChanged: false, + }) + ).not.toThrow(); + + expect(order).toEqual(['correctness', 'diagnostics']); + expect(facts).toEqual([ + { + kind: 'slotRenderEnded', + slot, + isEmpty: false, + size: [300, 250], + isBackfill: true, + slotContentChanged: false, + }, + ]); + expect(Object.isFrozen(facts[0])).toBe(true); + expect(Object.isFrozen((facts[0] as { size: unknown }).size)).toBe(true); + + releaseDiagnostics?.(); + emit({ slot, isEmpty: true }); + expect(facts).toHaveLength(1); + }); + + it('admits only one diagnostics observer without adding GPT listeners', () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const release = adapter.observeDiagnostics?.(vi.fn()); + + expect(adapter.observeDiagnostics?.(vi.fn())).toBeUndefined(); + expect(ready.pubads.addEventListener).not.toHaveBeenCalled(); + release?.(); + expect(adapter.observeDiagnostics?.(vi.fn())).toEqual(expect.any(Function)); + expect(ready.pubads.addEventListener).not.toHaveBeenCalled(); + }); + it('rolls back an exact GPT listener when installation replaces the binding', async () => { const first = createReadyGoogletag(); const replacement = createReadyGoogletag(); 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..085d89e0e --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/facts.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + GoogletagAdapter, + GoogletagDiagnosticsFact, + GoogletagDiagnosticsObserver, + GoogletagFacade, +} from '../../../src/adapters/googletag'; +import { + activateGptDiagnosticsFactCapture, + createGptDiagnosticsFactBuffer, +} from '../../../src/integrations/gpt_diagnostics/facts'; + +function fact(index: number): Readonly { + return Object.freeze({ kind: 'slotRequested', slot: Object.freeze({ index }) }); +} + +describe('GPT diagnostics fact transport', () => { + 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((item.slot as { index: number }).index); + }); + + 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 only the four non-correctness GPT listeners while active and disposes all ownership', async () => { + const subscriptions: string[] = []; + const releases: Array> = []; + let observer: GoogletagDiagnosticsObserver | undefined; + const operationDispose = vi.fn(); + const facade = Object.freeze({ + subscribe: (eventType: string, _listener: (event: unknown) => void) => { + subscriptions.push(eventType); + 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.sort()).toEqual( + ['impressionViewable', 'slotOnload', 'slotResponseReceived', 'slotVisibilityChanged'].sort() + ); + dispose(); + dispose(); + expect(operationDispose).toHaveBeenCalledOnce(); + expect(releases.every((release) => release.mock.calls.length === 1)).toBe(true); + expect(observer).toBeUndefined(); + }); +}); From f602d6bf6f4948712671f828cea0deac4c2bd0cb Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:56:11 -0700 Subject: [PATCH 124/194] Commit publisher GPT intent transactionally --- .../lib/src/services/slots.ts | 256 +++++++++++++++--- .../lib/test/services/slots.test.ts | 238 +++++++++++++++- 2 files changed, 442 insertions(+), 52 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 1521f50d6..fcbfe584b 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -2,6 +2,7 @@ import type { GoogletagAdapter, GoogletagFacade, GoogletagOperation, + GoogletagPublisherCallAdmission, GoogletagPublisherDefineSlotCall, GoogletagPublisherDisplayCall, GoogletagPublisherRefreshCall, @@ -155,12 +156,16 @@ export interface SlotService { ) => Readonly<{ action: 'forward' }> | Readonly<{ action: 'handoff'; slot: object }>; readonly preparePublisherDisplay: ( call: GoogletagPublisherDisplayCall - ) => Readonly<{ action: 'forward' }> | Readonly<{ action: 'suppress' }>; - readonly preparePublisherRefresh: ( - call: GoogletagPublisherRefreshCall ) => - | Readonly<{ action: 'forward' }> - | Readonly<{ action: 'replace'; slots: readonly object[] }> + | Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }> + | Readonly<{ action: 'suppress' }>; + readonly preparePublisherRefresh: (call: GoogletagPublisherRefreshCall) => + | Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }> + | Readonly<{ + action: 'replace'; + slots: readonly object[]; + admission?: GoogletagPublisherCallAdmission; + }> | Readonly<{ action: 'suppress' }>; readonly projectionRegistry: (owner: NavigationSession) => ProjectionSlotRegistry; readonly recordPublisherDestruction: (slot: object) => boolean; @@ -239,7 +244,7 @@ interface PhysicalSlot { lastResponseIdentifier: string | undefined; ownership: GptSlotOwnership; placementKeys: readonly string[]; - publisherIntentCount: number; + readonly publisherAdmissions: PublisherIntentAdmissionState[]; publisherElementIds: readonly string[]; suppressPublisherDisplay: boolean; suppressPublisherRefresh: boolean; @@ -251,6 +256,14 @@ interface PhysicalSlot { destroyAttempted: boolean; } +interface PublisherIntentAdmissionState { + enqueued: boolean; + phase: 'committed' | 'consumed' | 'pending' | 'rolled_back'; + readonly generation: object | undefined; + readonly physical: PhysicalSlot; + readonly record: InternalSlotRecord | undefined; +} + interface ReconciliationWindow { debounceTimer: ReturnType | undefined; deadlineTimer: ReturnType | undefined; @@ -956,7 +969,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { lastResponseIdentifier: undefined, ownership: 'trusted_server', placementKeys: oldPhysical.placementKeys, - publisherIntentCount: 0, + publisherAdmissions: [], publisherElementIds: Object.freeze([]), quarantineReason: undefined, record, @@ -1021,7 +1034,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { lastResponseIdentifier: undefined, ownership: 'trusted_server', placementKeys: source.placementKeys, - publisherIntentCount: 0, + publisherAdmissions: [], publisherElementIds: Object.freeze([]), quarantineReason: 'request', record: undefined, @@ -1372,15 +1385,20 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (!physical) return; if (type === 'slotRequested') { + const publisherAdmission = physical.publisherAdmissions[0]; + if (publisherAdmission) { + const phase = publisherAdmission.phase; + removePublisherAdmission(publisherAdmission); + publisherAdmission.phase = 'consumed'; + if (phase === 'pending') applyPublisherIntent(physical); + if (!physical.activeCycle && physical.state === 'live') { + physical.activeCycle = { intent: undefined, kind: 'publisher' }; + } + return; + } if (physical.state !== 'live' || physical.activeCycle) return; const record = physical.record; const intent = record?.activeIntent; - if (physical.publisherIntentCount > 0) { - physical.publisherIntentCount -= 1; - if (intent && !intent.terminal) settle(intent, failed('cycle_unattributable')); - physical.activeCycle = { intent: undefined, kind: 'publisher' }; - return; - } if ( intent && !intent.terminal && @@ -1457,6 +1475,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const retirePhysicalForNavigation = (physical: PhysicalSlot): void => { const record = physical.record; if (record) retireCommittedArtifact(record, physical); + invalidatePublisherAdmissions(physical); physical.record = undefined; if (physical.ownership === 'publisher') { if (physical.activeCycle) { @@ -1887,7 +1906,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if ( physical.ownership !== 'trusted_server' || physical.state !== 'live' || - physical.publisherIntentCount > 0 || + physical.publisherAdmissions.length > 0 || !physical.definition ) { cancelReconciliation(record); @@ -2250,7 +2269,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { lastResponseIdentifier: undefined, ownership, placementKeys: bindingPlacementKeys, - publisherIntentCount: 0, + publisherAdmissions: [], publisherElementIds: ownership === 'publisher' && definition ? Object.freeze([definition.elementId]) @@ -2332,7 +2351,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { settle(intent, failed('gpt_request_failed')); return handle; } - if (physical.publisherIntentCount > 0) { + if (physical.publisherAdmissions.length > 0) { settle(intent, failed('cycle_unattributable')); return handle; } @@ -2462,7 +2481,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { physical.record !== record || physical.state !== 'live' || physical.activeCycle || - physical.publisherIntentCount > 0 || + physical.publisherAdmissions.length > 0 || setHasValue(admittedRecords, record) || setHasValue(admittedPhysicalSlots, physical.slot) ) { @@ -2660,7 +2679,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (record?.physical === physical) record.physical = undefined; physical.record = undefined; physical.activeCycle = undefined; - physical.publisherIntentCount = 0; + invalidatePublisherAdmissions(physical); physical.publisherElementIds = Object.freeze([]); physical.suppressPublisherDisplay = false; physical.suppressPublisherRefresh = false; @@ -2673,36 +2692,173 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return true; }; - const recordPublisherIntent = (slot: object): boolean => { - const physical = weakMapValue(physicalByObject, slot); - if (!physical || (physical.state !== 'live' && !physical.activeCycle)) return false; - if (physical.publisherIntentCount >= MAX_PENDING_PUBLISHER_INTENTS) { - physical.state = 'quarantined'; - physical.quarantineReason = 'request'; - quarantinePhysicalPlacement(physical); - if (physical.record?.activeIntent) { - settle(physical.record.activeIntent, failed('cycle_unattributable')); - } - if (physical.record?.queuedIntent) { - settle(physical.record.queuedIntent, failed('cycle_unattributable')); + const publisherAdmissionIndex = (state: PublisherIntentAdmissionState): number => { + const admissions = state.physical.publisherAdmissions; + for (let index = 0; index < admissions.length; index += 1) { + if (admissions[index] === state) return index; + } + return -1; + }; + + const invalidatePublisherAdmissions = (physical: PhysicalSlot): void => { + for (let index = 0; index < physical.publisherAdmissions.length; index += 1) { + const admission = physical.publisherAdmissions[index]; + if (!admission) continue; + admission.enqueued = false; + if (admission.phase === 'pending' || admission.phase === 'committed') { + admission.phase = 'rolled_back'; } + } + physical.publisherAdmissions.length = 0; + }; + + const removePublisherAdmission = (state: PublisherIntentAdmissionState): boolean => { + if (!state.enqueued) return false; + const physical = state.physical; + const admissions = physical.publisherAdmissions; + const admissionIndex = publisherAdmissionIndex(state); + state.enqueued = false; + if (admissionIndex < 0) return false; + for (let index = admissionIndex; index < admissions.length - 1; index += 1) { + const next = admissions[index + 1]; + if (next) admissions[index] = next; + } + admissions.length -= 1; + return true; + }; + + const publisherAdmissionIsCurrent = (state: PublisherIntentAdmissionState): boolean => { + const physical = state.physical; + if ( + !state.enqueued || + publisherAdmissionIndex(state) < 0 || + weakMapValue(physicalByObject, physical.slot) !== physical || + physical.record !== state.record || + (physical.state !== 'live' && !physical.activeCycle) + ) { return false; } + const record = state.record; + return ( + !record || + (!record.state.disposed && + record.state.owner.isCurrent() && + record.state.owner.generation === state.generation) + ); + }; + + const failPublisherIntentOverflow = (physical: PhysicalSlot): void => { + if (physical.state === 'retired') return; + physical.state = 'quarantined'; + physical.quarantineReason = 'request'; + quarantinePhysicalPlacement(physical); + if (physical.record?.activeIntent) { + settle(physical.record.activeIntent, failed('cycle_unattributable')); + } + if (physical.record?.queuedIntent) { + settle(physical.record.queuedIntent, failed('cycle_unattributable')); + } + }; + + const applyPublisherIntent = (physical: PhysicalSlot): void => { if (physical.record?.activeIntent) { settle(physical.record.activeIntent, failed('cycle_unattributable')); } if (physical.record?.queuedIntent) { settle(physical.record.queuedIntent, failed('cycle_unattributable')); } - physical.publisherIntentCount += 1; if (physical.activeCycle?.kind === 'trusted_server') { physical.activeCycle = { intent: undefined, kind: 'publisher' }; physical.state = 'quarantined'; physical.quarantineReason = 'completion'; } + }; + + const rollbackPublisherAdmission = (state: PublisherIntentAdmissionState): void => { + if (state.phase !== 'pending') return; + removePublisherAdmission(state); + state.phase = 'rolled_back'; + }; + + const commitPublisherAdmission = (state: PublisherIntentAdmissionState): boolean => { + if (state.phase === 'committed' || state.phase === 'consumed') return true; + if (state.phase !== 'pending') return false; + if (!publisherAdmissionIsCurrent(state)) { + rollbackPublisherAdmission(state); + return false; + } + const physical = state.physical; + if (physical.publisherAdmissions.length > MAX_PENDING_PUBLISHER_INTENTS) { + removePublisherAdmission(state); + state.phase = 'rolled_back'; + failPublisherIntentOverflow(physical); + return false; + } + state.phase = 'committed'; + applyPublisherIntent(physical); return true; }; + const preparePublisherIntent = ( + slot: object + ): + | Readonly<{ + readonly admission: GoogletagPublisherCallAdmission; + readonly commit: () => boolean; + }> + | undefined => { + const physical = weakMapValue(physicalByObject, slot); + if ( + !physical || + (physical.state !== 'live' && !physical.activeCycle) || + physical.publisherAdmissions.length > MAX_PENDING_PUBLISHER_INTENTS + ) { + return undefined; + } + const record = physical.record; + const state: PublisherIntentAdmissionState = { + enqueued: true, + generation: record?.state.owner.generation, + phase: 'pending', + physical, + record, + }; + physical.publisherAdmissions[physical.publisherAdmissions.length] = state; + const admission: GoogletagPublisherCallAdmission = Object.freeze({ + commit: (): void => { + commitPublisherAdmission(state); + }, + rollback: (): void => { + rollbackPublisherAdmission(state); + }, + }); + return Object.freeze({ admission, commit: () => commitPublisherAdmission(state) }); + }; + + const compositePublisherAdmission = ( + admissions: readonly GoogletagPublisherCallAdmission[] + ): GoogletagPublisherCallAdmission | undefined => { + if (admissions.length === 0) return undefined; + return Object.freeze({ + commit: (): void => { + for (let index = 0; index < admissions.length; index += 1) { + admissions[index]?.commit(); + } + }, + rollback: (): void => { + for (let index = admissions.length - 1; index >= 0; index -= 1) { + admissions[index]?.rollback(); + } + }, + }); + }; + + const recordPublisherIntent = (slot: object): boolean => { + const prepared = preparePublisherIntent(slot); + if (!prepared) return false; + return prepared.commit(); + }; + const publisherPhysicalForTarget = (target: unknown): PhysicalSlot | undefined => { if ((typeof target === 'object' && target !== null) || typeof target === 'function') { const exact = weakMapValue(physicalByObject, target as object); @@ -2818,7 +2974,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const preparePublisherDisplay = ( call: GoogletagPublisherDisplayCall - ): Readonly<{ action: 'forward' }> | Readonly<{ action: 'suppress' }> => { + ): + | Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }> + | Readonly<{ action: 'suppress' }> => { let target: unknown; let initialLoadDisabled: unknown; try { @@ -2833,15 +2991,22 @@ export function createSlotService(options: SlotServiceOptions): SlotService { physical.suppressPublisherDisplay = false; return Object.freeze({ action: 'suppress' }); } - if (initialLoadDisabled !== true) recordPublisherIntent(physical.slot); - return Object.freeze({ action: 'forward' }); + const prepared = + initialLoadDisabled === true ? undefined : preparePublisherIntent(physical.slot); + return prepared + ? Object.freeze({ action: 'forward', admission: prepared.admission }) + : Object.freeze({ action: 'forward' }); }; const preparePublisherRefresh = ( call: GoogletagPublisherRefreshCall ): - | Readonly<{ action: 'forward' }> - | Readonly<{ action: 'replace'; slots: readonly object[] }> + | Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }> + | Readonly<{ + action: 'replace'; + slots: readonly object[]; + admission?: GoogletagPublisherCallAdmission; + }> | Readonly<{ action: 'suppress' }> => { let slots: readonly object[]; try { @@ -2852,6 +3017,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (!Array.isArray(slots)) return Object.freeze({ action: 'forward' }); let suppressed = false; const forwarded: object[] = []; + const admissions: GoogletagPublisherCallAdmission[] = []; for (let index = 0; index < slots.length; index += 1) { const slot = slots[index]; if (!slot) continue; @@ -2862,11 +3028,21 @@ export function createSlotService(options: SlotServiceOptions): SlotService { continue; } forwarded[forwarded.length] = slot; - if (physical?.ownership === 'publisher') recordPublisherIntent(slot); + if (physical?.ownership === 'publisher') { + const prepared = preparePublisherIntent(slot); + if (prepared) admissions[admissions.length] = prepared.admission; + } + } + const admission = compositePublisherAdmission(admissions); + if (!suppressed) { + return admission + ? Object.freeze({ action: 'forward', admission }) + : Object.freeze({ action: 'forward' }); } - if (!suppressed) return Object.freeze({ action: 'forward' }); if (forwarded.length === 0) return Object.freeze({ action: 'suppress' }); - return Object.freeze({ action: 'replace', slots: Object.freeze(forwarded) }); + return admission + ? Object.freeze({ action: 'replace', admission, slots: Object.freeze(forwarded) }) + : Object.freeze({ action: 'replace', slots: Object.freeze(forwarded) }); }; const service: SlotService = Object.freeze({ diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index b903026fd..0c8be81c8 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -5,6 +5,7 @@ import { GoogletagReplacementError, type GoogletagAdapter, type GoogletagFacade, + type GoogletagPublisherCallAdmission, type GoogletagReplacementCommitAdmission, type GoogletagReplacementDefinition, } from '../../src/adapters/googletag'; @@ -550,12 +551,15 @@ describe('slot registry', () => { slots: Object.freeze([slot, unrelated]), }) ).toEqual({ action: 'replace', slots: [unrelated] }); - expect( - service.preparePublisherRefresh({ - requestedSlots: Object.freeze([slot]), - slots: Object.freeze([slot]), - }) - ).toEqual({ action: 'forward' }); + 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', @@ -593,6 +597,213 @@ describe('slot registry', () => { 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 = {}; @@ -667,12 +878,15 @@ describe('slot registry', () => { slots: Object.freeze([slot]), }) ).toEqual({ action: 'suppress' }); - expect( - service.preparePublisherRefresh({ - requestedSlots: Object.freeze([slot]), - slots: Object.freeze([slot]), - }) - ).toEqual({ action: 'forward' }); + 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', () => { From b555d4210e28db6372a94941fc121296c7490f5e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:00:57 -0700 Subject: [PATCH 125/194] Name and complete ts_console request gates --- .../src/integrations/gpt_diagnostics.rs | 58 ++++++++++++++++--- crates/trusted-server-core/src/publisher.rs | 2 +- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs index 068c43cc0..350a09cf8 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs @@ -371,7 +371,7 @@ mod tests { } #[test] - fn register_excludes_diagnostics_from_unified_and_deferred_bundles() { + fn ts_console_register_excludes_diagnostics_from_unified_and_deferred_bundles() { let registry = IntegrationRegistry::new(&settings(true)).expect("should build registry"); assert!(registry.integration_enabled(GPT_DIAGNOSTICS_INTEGRATION_ID)); @@ -388,7 +388,7 @@ mod tests { } #[test] - fn exact_directive_activates_cleans_and_strips_cookie() { + fn ts_console_exact_directive_activates_cleans_and_strips_cookie() { let mut request = navigation( "https://publisher.example/page?keep=%2F&ts_console=true#fragment", Some("other=value; __Host-ts-console=1"), @@ -412,7 +412,7 @@ mod tests { } #[test] - fn prefetch_directive_is_sanitized_without_activating_session() { + fn ts_console_prefetch_directive_is_sanitized_without_activating_session() { let mut request = navigation("https://publisher.example/page?ts_console=1&keep=1", None); request .headers_mut() @@ -429,7 +429,7 @@ mod tests { } #[test] - fn active_cookie_enables_clean_navigation_but_duplicates_fail_closed() { + fn ts_console_active_cookie_enables_clean_navigation_but_duplicates_fail_closed() { let mut active = navigation( "https://publisher.example/page", Some("__Host-ts-console=1; other=value"), @@ -448,7 +448,7 @@ mod tests { } #[test] - fn invalid_duplicate_and_disable_directives_fail_closed() { + fn ts_console_invalid_duplicate_and_disable_directives_fail_closed() { for query in [ "ts_console=True", "ts_console=", @@ -477,7 +477,7 @@ mod tests { } #[test] - fn finalization_sets_cookie_and_strips_shared_cache_headers() { + fn ts_console_finalization_sets_cookie_and_strips_shared_cache_headers() { let mut request = navigation("https://publisher.example/?ts_console=1", None); let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); let mut response = Response::builder() @@ -505,7 +505,51 @@ mod tests { } #[test] - fn config_rejects_unknown_fields() { + fn ts_console_only_eligible_get_document_navigations_activate() { + for (method, destination) in [(Method::POST, "document"), (Method::GET, "script")] { + let mut request = Request::builder() + .method(method.clone()) + .uri("https://publisher.example/page?keep=1&ts_console=1") + .header("sec-fetch-dest", destination) + .header(header::COOKIE, "__Host-ts-console=1; other=value") + .body(EdgeBody::empty()) + .expect("should build ineligible request"); + + let decision = prepare_request(&settings(true), &mut request) + .expect("should sanitize ineligible request"); + + assert!( + !decision.active(), + "{method} {destination} must not activate" + ); + assert_eq!(decision.cookie_action, GptDiagnosticsCookieAction::None); + assert_eq!(request.uri().query(), Some("keep=1")); + assert_eq!(request.headers()[header::COOKIE], "other=value"); + } + } + + #[test] + fn ts_console_disable_emits_the_exact_session_cookie_clear() { + let mut request = navigation( + "https://publisher.example/page?ts_console=0&keep=1", + Some("__Host-ts-console=1"), + ); + let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + let mut response = Response::new(EdgeBody::empty()); + + finalize_response(&decision, &mut response); + + assert!(!decision.active()); + assert_eq!(request.uri().query(), Some("keep=1")); + assert_eq!(response.headers()[header::SET_COOKIE], CLEAR_CONSOLE_COOKIE); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + } + + #[test] + fn ts_console_config_rejects_unknown_fields() { let mut settings = create_test_settings(); settings .integrations diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 778addf82..cd1852162 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -5149,7 +5149,7 @@ mod tests { } #[test] - fn stream_publisher_body_injects_active_diagnostics_for_materialized_html() { + fn ts_console_stream_publisher_body_injects_active_diagnostics_for_materialized_html() { let mut settings = create_test_settings(); settings .integrations From 03d85614bfb7b8d922a5e9c360ca84d2e95c33ff Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:07:57 -0700 Subject: [PATCH 126/194] Rebuild bounded GPT diagnostics --- .../lib/eslint-rules/no-adtech-globals.js | 1 - .../lib/src/adapters/googletag.ts | 2 +- .../lib/src/composition/browser.ts | 51 +++- .../src/integrations/gpt_diagnostics/facts.ts | 7 +- .../src/integrations/gpt_diagnostics/index.ts | 162 +++++------ .../integrations/gpt_diagnostics/module.ts | 91 ++++++ .../integrations/gpt_diagnostics/observer.ts | 177 ++++-------- .../lib/test/composition/browser.test.ts | 201 ++++++++++++- .../test/eslint/no-adtech-globals.test.mjs | 1 - .../gpt_diagnostics/facts.test.ts | 25 +- .../gpt_diagnostics/index.test.ts | 241 +++++----------- .../gpt_diagnostics/module.test.ts | 111 ++++++++ .../gpt_diagnostics/observer.test.ts | 263 +++--------------- .../lib/test/services/slots.test.ts | 2 + 14 files changed, 722 insertions(+), 613 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/module.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/module.test.ts 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 index 78aff60b6..d2c4d2b4a 100644 --- a/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js +++ b/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js @@ -7,7 +7,6 @@ const GLOBAL_ROOTS = new Set(['globalThis', 'self', 'window']); export const LEGACY_ADTECH_GLOBAL_ALLOWLIST = Object.freeze([ 'src/integrations/gpt/index.ts', - 'src/integrations/gpt_diagnostics/observer.ts', 'src/integrations/prebid/index.ts', ]); diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index 4585a25eb..5d65079fd 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -183,7 +183,7 @@ export interface GoogletagOperation { /** Narrow GPT boundary consumed by kernel sessions and services. */ export interface GoogletagAdapter { bindingStatus(): GoogletagBindingStatus; - observeDiagnostics?(observer: GoogletagDiagnosticsObserver): (() => void) | undefined; + observeDiagnostics(observer: GoogletagDiagnosticsObserver): (() => void) | undefined; observePublisherCalls(observer: GoogletagPublisherCallObserver): () => void; run( command: (googletag: Readonly) => T, diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 60e369886..c8dd23d22 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -54,6 +54,15 @@ import { type GptWinnerPublicationResult, } from '../integrations/gpt/module'; import { createGptStartup } from '../integrations/gpt/startup'; +import { + activateGptDiagnosticsFactCapture, + createGptDiagnosticsFactBuffer, + type GptDiagnosticsFactBuffer, +} from '../integrations/gpt_diagnostics/facts'; +import { + createGptDiagnosticsRuntime, + type GptDiagnosticsRuntime, +} from '../integrations/gpt_diagnostics'; import { createPrebidSelectionCoordinator, publishPrebidBid, @@ -287,7 +296,10 @@ export function createTestBrowserRuntimeComposition( const providedBindings = runtimeOptions.getBindings; let browserServices: Readonly | undefined; let creativeBoot: Readonly | undefined; + let diagnosticsBoot: Readonly | undefined; let diagnosticsBus: DiagnosticsBus | undefined; + let gptDiagnosticsFacts: GptDiagnosticsFactBuffer | undefined; + let gptDiagnosticsRuntime: GptDiagnosticsRuntime | undefined; let renderTrace: RenderTraceRuntimeOwner | undefined; const consumeCoreObservation = (observation: DiagnosticsObservation): void => { if ( @@ -326,7 +338,11 @@ export function createTestBrowserRuntimeComposition( const diagnosticsForPublish = (): Readonly => { const trace = renderTrace; if (!trace) throw new Error('Render diagnostics are unavailable'); - return Object.freeze({ renderTrace: trace.diagnostics }); + const gpt = gptDiagnosticsRuntime?.currentApi(); + if (diagnosticsBoot?.gpt.active && !gpt) { + throw new Error('GPT diagnostics are unavailable'); + } + return Object.freeze({ renderTrace: trace.diagnostics, ...(gpt ? { gpt } : {}) }); }; const defaultCreativeRuntime = typeof document === 'undefined' @@ -434,6 +450,7 @@ export function createTestBrowserRuntimeComposition( 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({ @@ -633,6 +650,7 @@ export function createTestBrowserRuntimeComposition( 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 => @@ -652,9 +670,26 @@ export function createTestBrowserRuntimeComposition( }); renderTrace = preparedRenderTrace; diagnosticsBus = preparedDiagnosticsBus; + const preparedGptDiagnosticsFacts = boot.diagnostics.gpt.active + ? createGptDiagnosticsFactBuffer({ + onConsumerError: (error) => log.warn('gpt diagnostics: fact consumer failed', error), + }) + : undefined; + const preparedGptDiagnosticsRuntime = preparedGptDiagnosticsFacts + ? createGptDiagnosticsRuntime(preparedGptDiagnosticsFacts) + : undefined; + gptDiagnosticsFacts = preparedGptDiagnosticsFacts; + gptDiagnosticsRuntime = preparedGptDiagnosticsRuntime; context.onDispose(() => { + preparedGptDiagnosticsFacts?.dispose(); preparedDiagnosticsBus.dispose(); preparedRenderTrace.dispose(); + if (gptDiagnosticsFacts === preparedGptDiagnosticsFacts) { + gptDiagnosticsFacts = undefined; + } + if (gptDiagnosticsRuntime === preparedGptDiagnosticsRuntime) { + gptDiagnosticsRuntime = undefined; + } if (diagnosticsBus === preparedDiagnosticsBus) diagnosticsBus = undefined; if (renderTrace === preparedRenderTrace) renderTrace = undefined; }); @@ -855,6 +890,9 @@ export function createTestBrowserRuntimeComposition( adapters: composition.adapters, creative: creativeRuntime, diagnostics: Object.freeze({ subscribe: preparedDiagnosticsBus.subscribe }), + ...(preparedGptDiagnosticsRuntime + ? { gpt_diagnostics: preparedGptDiagnosticsRuntime } + : {}), gpt: gptRuntime, prebid: prebidRuntime, ...services, @@ -880,6 +918,7 @@ export function createTestBrowserRuntimeComposition( auctionContextRegistry = undefined; projectionParser = undefined; creativeBoot = undefined; + diagnosticsBoot = undefined; } }); const navigation = session.startInitialNavigation(initialProjection); @@ -912,6 +951,15 @@ export function createTestBrowserRuntimeComposition( activateCore: (context) => { const prepared = preparedBrowserServices; if (!prepared) throw new Error('Browser services are unavailable'); + const facts = gptDiagnosticsFacts; + if (facts) { + const releaseCapture = activateGptDiagnosticsFactCapture( + composition.adapters.googletag, + facts + ); + if (!releaseCapture) throw new Error('GPT diagnostics capture is unavailable'); + context.onDispose(releaseCapture); + } const pucBridge = createPucBridge({ messaging: composition.adapters.messaging, publisherOrigin: prepared.publisherOrigin, @@ -952,6 +1000,7 @@ export function createTestBrowserRuntimeComposition( if (prebidCoordinator === coordinator) prebidCoordinator = undefined; }); browserServices.slots.activate(); + browserServices.slots.start(); compositionOptions.coreActivations.correctnessGptListeners( context, composition.adapters, diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts index 623ac9ba5..9c3ef2375 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts @@ -122,11 +122,10 @@ export function createGptDiagnosticsFactBuffer( export function activateGptDiagnosticsFactCapture( adapter: Pick, buffer: Pick -): () => void { +): (() => void) | undefined { let disposed = false; let releases: readonly (() => void)[] = Object.freeze([]); const observeDiagnostics = adapter.observeDiagnostics; - if (!observeDiagnostics) return () => undefined; const releaseObserver = observeDiagnostics((fact) => { try { buffer.publish(fact); @@ -134,7 +133,7 @@ export function activateGptDiagnosticsFactCapture( // Fact buffering cannot alter the already-completed GPT callback. } }); - if (!releaseObserver) return () => undefined; + if (!releaseObserver) return undefined; let operation: ReturnType | undefined; try { @@ -181,7 +180,7 @@ export function activateGptDiagnosticsFactCapture( ); } catch { releaseObserver(); - return () => undefined; + return undefined; } return (): void => { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts index 1265585b0..e552f9112 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts @@ -1,101 +1,107 @@ -import { log } from '../../core/log'; -import type { GptDiagnosticsApi, LegacyTsjsApi } from '../../core/types'; +import type { GptDiagnosticsApi } from '../../core/types'; import { GptDiagnosticsApiController } from './api'; import { GptDiagnosticsBadgeManager } from './badges'; import { GptDiagnosticsBindingManager } from './binding'; +import type { GptDiagnosticsFactBuffer } from './facts'; import { GptDiagnosticsObserver } from './observer'; -import type { GptObserverWindow } from './observer'; import { GptDiagnosticsOverlay } from './overlay'; import { GptDiagnosticsStore } from './store'; -interface GptDiagnosticsRuntime { - api: GptDiagnosticsApi; - destroy(): void; +type GptDiagnosticsWindow = Window & typeof globalThis; + +export interface GptDiagnosticsRuntimeOptions { + readonly document?: Document | undefined; + readonly window?: GptDiagnosticsWindow | undefined; } -type GptDiagnosticsWindow = Window & - typeof globalThis & - GptObserverWindow & { - __tsjs_gpt_diagnostics_active?: boolean; - __tsjs_gpt_diagnostics_runtime?: GptDiagnosticsRuntime; - tsjs?: LegacyTsjsApi; - }; +export interface GptDiagnosticsRuntime { + readonly activate: () => () => void; + readonly currentApi: () => GptDiagnosticsApi | undefined; +} -/** Whether the early bootstrap activated diagnostics for this document. */ -export function isGptDiagnosticsActive( - target: Pick< - GptDiagnosticsWindow, - '__tsjs_gpt_diagnostics_active' - > = window as GptDiagnosticsWindow -): boolean { - return target.__tsjs_gpt_diagnostics_active === true; +interface ActiveRuntime { + readonly api: GptDiagnosticsApi; + readonly release: () => void; } -/** Installs one active diagnostics runtime for the current document. */ -export function installGptDiagnosticsRuntime( - target: GptDiagnosticsWindow = window as GptDiagnosticsWindow -): GptDiagnosticsApi | undefined { - if (!isGptDiagnosticsActive(target)) return undefined; - if (target.__tsjs_gpt_diagnostics_runtime) { - return target.__tsjs_gpt_diagnostics_runtime.api; +function isolate(callback: () => void): void { + try { + callback(); + } catch { + // Diagnostics cleanup cannot retain another independently owned resource. } +} - let bindings: GptDiagnosticsBindingManager | undefined; - let badges: GptDiagnosticsBadgeManager | undefined; - let overlay: GptDiagnosticsOverlay | undefined; - let apiController: GptDiagnosticsApiController | undefined; +/** Creates an inert GPT diagnostics runtime over the adapter-owned fact transport. */ +export function createGptDiagnosticsRuntime( + facts: Pick, + options: GptDiagnosticsRuntimeOptions = {} +): GptDiagnosticsRuntime { + const targetWindow = options.window ?? (window as GptDiagnosticsWindow); + const targetDocument = options.document ?? document; + let active: ActiveRuntime | undefined; - try { - if (!target.tsjs) throw new Error('TSJS core API unavailable'); + const activate = (): (() => void) => { + if (active) throw new Error('GPT diagnostics runtime is already active'); const store = new GptDiagnosticsStore(); - const observer = new GptDiagnosticsObserver(store, { window: target }); - bindings = new GptDiagnosticsBindingManager(store, { - window: target, - document: target.document, - }); - badges = new GptDiagnosticsBadgeManager(store, bindings, { - window: target, - document: target.document, - }); - overlay = new GptDiagnosticsOverlay(store, bindings, { - window: target, - document: target.document, - onExport: () => apiController?.api.export(), - onBadgeLayerChange: (layer) => badges?.setLayer(layer), - }); - apiController = new GptDiagnosticsApiController(store, bindings, overlay, { - window: target, - document: target.document, - }); + 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; + } - observer.install(); const api = apiController.api; - const runtime: GptDiagnosticsRuntime = { - api, - destroy: () => { - if (target.tsjs?.gptDiagnostics === api) delete target.tsjs.gptDiagnostics; - apiController?.destroy(); - overlay?.destroy(); - badges?.destroy(); - bindings?.destroy(); - delete target.__tsjs_gpt_diagnostics_runtime; - }, + let released = false; + const release = (): void => { + if (released) return; + released = true; + if (active?.release === release) active = undefined; + cleanup(); }; - target.tsjs.gptDiagnostics = api; - target.__tsjs_gpt_diagnostics_runtime = runtime; - return api; - } catch (error) { - apiController?.destroy(); - overlay?.destroy(); - badges?.destroy(); - bindings?.destroy(); - log.warn('gpt diagnostics: runtime installation failed', error); - return undefined; - } -} + active = Object.freeze({ api, release }); + return release; + }; -if (typeof window !== 'undefined' && isGptDiagnosticsActive(window as GptDiagnosticsWindow)) { - installGptDiagnosticsRuntime(window as GptDiagnosticsWindow); + return Object.freeze({ + activate, + currentApi: (): GptDiagnosticsApi | undefined => active?.api, + }); } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/module.ts new file mode 100644 index 000000000..7c928376b --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/module.ts @@ -0,0 +1,91 @@ +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from '../../kernel/integration_registry'; + +import type { GptDiagnosticsRuntime } from './index'; + +export const GPT_DIAGNOSTICS_INTEGRATION_ID = 'gpt_diagnostics' as const; + +function activeConfiguration(candidate: unknown): boolean { + 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 active = Object.getOwnPropertyDescriptor(candidate, 'active'); + return Boolean(active?.enumerable && 'value' in active && active.value === true); + } catch { + return false; + } +} + +function diagnosticsRuntime( + interfaces: Readonly> +): GptDiagnosticsRuntime | undefined { + try { + const descriptor = Object.getOwnPropertyDescriptor(interfaces, GPT_DIAGNOSTICS_INTEGRATION_ID); + if (!descriptor || !('value' in descriptor)) return undefined; + const candidate = descriptor.value; + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + !Object.isFrozen(candidate) || + Reflect.ownKeys(candidate).length !== 2 + ) { + return undefined; + } + const activate = Object.getOwnPropertyDescriptor(candidate, 'activate'); + const currentApi = Object.getOwnPropertyDescriptor(candidate, 'currentApi'); + if ( + !activate?.enumerable || + !('value' in activate) || + typeof activate.value !== 'function' || + !currentApi?.enumerable || + !('value' in currentApi) || + typeof currentApi.value !== 'function' + ) { + return undefined; + } + return candidate as GptDiagnosticsRuntime; + } catch { + return undefined; + } +} + +/** Builds the release-bound GPT diagnostics module for the coordinated runtime. */ +export function createGptDiagnosticsIntegrationRegistration( + release: string +): IntegrationRegistration { + return Object.freeze({ + id: GPT_DIAGNOSTICS_INTEGRATION_ID, + release, + prepare: ({ config, interfaces }: IntegrationPrepareContext) => { + if (!activeConfiguration(config)) { + throw new TypeError('GPT diagnostics integration config is invalid'); + } + const runtime = diagnosticsRuntime(interfaces); + if (!runtime) throw new TypeError('GPT diagnostics integration runtime is unavailable'); + + return Object.freeze({ + activate: ({ onDispose }: IntegrationActivationContext) => { + const ownership: { release?: () => void } = {}; + onDispose(() => ownership.release?.()); + const releaseRuntime = runtime.activate(); + if (typeof releaseRuntime !== 'function') { + throw new TypeError('GPT diagnostics integration disposer is unavailable'); + } + ownership.release = releaseRuntime; + }, + }); + }, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts index 12c5d64ae..472ee30a6 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts @@ -1,3 +1,4 @@ +import type { GoogletagDiagnosticsFact } from '../../adapters/googletag'; import { log } from '../../core/log'; import type { Size } from '../../core/types'; @@ -13,161 +14,79 @@ export interface GptDiagnosticsObserverStore { recordSlotVisibilityChanged(slot: GptDiagnosticsSlotLike, percentage: number): void; } -interface GptEvent { - slot: GptDiagnosticsSlotLike; -} - -interface GptRenderEvent extends GptEvent { - isEmpty?: boolean | undefined; - size?: unknown; - isBackfill?: boolean | undefined; - slotContentChanged?: boolean | undefined; -} - -interface GptVisibilityEvent extends GptEvent { - inViewPercentage: number; -} - -type GptEventName = - | 'slotRequested' - | 'slotResponseReceived' - | 'slotRenderEnded' - | 'slotOnload' - | 'impressionViewable' - | 'slotVisibilityChanged'; - -type GptEventListener = (event: GptEvent) => void; - -interface GptPubAdsService { - addEventListener(name: GptEventName, listener: GptEventListener): void; -} - -interface GptCommandQueue { - push(...callbacks: Array<() => void>): number; -} - -interface GoogletagLike { - cmd: GptCommandQueue; - pubads?: (() => GptPubAdsService) | undefined; -} - -export interface GptObserverWindow { - googletag?: GoogletagLike | undefined; -} - interface ObserverLogger { - warn(...args: unknown[]): void; + warn(...args: unknown[]): unknown; } interface ObserverOptions { - window?: GptObserverWindow | undefined; - logger?: ObserverLogger | undefined; -} - -function normalizeSize(value: unknown): Size | undefined { - if ( - !Array.isArray(value) || - value.length !== 2 || - typeof value[0] !== 'number' || - typeof value[1] !== 'number' || - !Number.isFinite(value[0]) || - !Number.isFinite(value[1]) - ) { - return undefined; - } - - return [value[0], value[1]]; + readonly logger?: ObserverLogger | undefined; } -/** Installs documented GPT event listeners through `googletag.cmd`. */ +/** Consumes normalized facts from the sole GPT adapter without owning browser-global access. */ export class GptDiagnosticsObserver { private readonly store: GptDiagnosticsObserverStore; - private readonly window: GptObserverWindow; private readonly logger: ObserverLogger; - private queued = false; - private installed = false; + private started = false; constructor(store: GptDiagnosticsObserverStore, options: ObserverOptions = {}) { this.store = store; - this.window = options.window ?? (window as unknown as GptObserverWindow); this.logger = options.logger ?? log; } - install(): void { - if (this.queued || this.installed) return; - this.queued = true; - - try { - const googletag = (this.window.googletag ??= { cmd: [] }); - googletag.cmd ??= []; - googletag.cmd.push(() => this.installWhenReady(googletag)); - } catch (error) { - this.queued = false; - this.logger.warn('gpt diagnostics: command queue installation failed', error); - } + start(): void { + if (this.started) return; + this.started = true; + this.handle('activation', () => this.store.markGptObserved()); } - private installWhenReady(googletag: GoogletagLike): void { - if (this.installed) return; - - try { - const pubads = googletag.pubads?.(); - if (!pubads || typeof pubads.addEventListener !== 'function') { - this.logger.warn('gpt diagnostics: PubAdsService unavailable'); + consume(fact: Readonly): void { + this.start(); + const slot = fact.slot as GptDiagnosticsSlotLike; + switch (fact.kind) { + case 'slotRequested': + this.handle(fact.kind, () => this.store.recordSlotRequested(slot)); return; - } - - pubads.addEventListener('slotRequested', (event) => { - this.handle('slotRequested', () => this.store.recordSlotRequested(event.slot)); - }); - pubads.addEventListener('slotResponseReceived', (event) => { - this.handle('slotResponseReceived', () => - this.store.recordSlotResponseReceived(event.slot) + case 'slotResponseReceived': + this.handle(fact.kind, () => this.store.recordSlotResponseReceived(slot)); + return; + case 'slotRenderEnded': + this.handle(fact.kind, () => + this.store.recordSlotRenderEnded(slot, { + isEmpty: fact.isEmpty, + size: fact.size ? ([...fact.size] as Size) : undefined, + isBackfill: fact.isBackfill, + slotContentChanged: fact.slotContentChanged, + }) ); - }); - pubads.addEventListener('slotRenderEnded', (event) => { - this.handle('slotRenderEnded', () => { - const renderEvent = event as GptRenderEvent; - this.store.recordSlotRenderEnded(renderEvent.slot, { - isEmpty: typeof renderEvent.isEmpty === 'boolean' ? renderEvent.isEmpty : undefined, - size: normalizeSize(renderEvent.size), - isBackfill: - typeof renderEvent.isBackfill === 'boolean' ? renderEvent.isBackfill : undefined, - slotContentChanged: - typeof renderEvent.slotContentChanged === 'boolean' - ? renderEvent.slotContentChanged - : undefined, - }); - }); - }); - pubads.addEventListener('slotOnload', (event) => { - this.handle('slotOnload', () => this.store.recordSlotOnload(event.slot)); - }); - pubads.addEventListener('impressionViewable', (event) => { - this.handle('impressionViewable', () => this.store.recordImpressionViewable(event.slot)); - }); - pubads.addEventListener('slotVisibilityChanged', (event) => { - this.handle('slotVisibilityChanged', () => { - const visibilityEvent = event as GptVisibilityEvent; + return; + case 'slotOnload': + this.handle(fact.kind, () => this.store.recordSlotOnload(slot)); + return; + case 'impressionViewable': + this.handle(fact.kind, () => this.store.recordImpressionViewable(slot)); + return; + case 'slotVisibilityChanged': + this.handle(fact.kind, () => this.store.recordSlotVisibilityChanged( - visibilityEvent.slot, - visibilityEvent.inViewPercentage - ); - }); - }); - - this.installed = true; - this.store.markGptObserved(); - } catch (error) { - this.logger.warn('gpt diagnostics: listener installation failed', error); + slot, + typeof fact.inViewPercentage === 'number' ? fact.inViewPercentage : Number.NaN + ) + ); } } - private handle(kind: GptEventName, callback: () => void): void { + private handle( + kind: GoogletagDiagnosticsFact['kind'] | 'activation', + callback: () => void + ): void { try { callback(); } catch (error) { - this.logger.warn(`gpt diagnostics: ${kind} callback failed`, error); + try { + this.logger.warn(`gpt diagnostics: ${kind} callback failed`, error); + } catch { + // Diagnostics logging cannot escape into adapter fact delivery. + } } } } diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index c1e846e34..c5af73d33 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -5,6 +5,7 @@ import { createNoopGoogletagAdapter, type GoogletagAdapter, type GoogletagBindingStatus, + type GoogletagDiagnosticsObserver, type GoogletagFacade, } from '../../src/adapters/googletag'; import { @@ -32,6 +33,7 @@ import type { BrowserAuctionBidV1 } from '../../src/core/types'; import { createCreativeIntegrationRegistration } from '../../src/integrations/creative/module'; import { createGptIntegrationRegistration } from '../../src/integrations/gpt/module'; import { isGuardInstalled, resetGuardState } from '../../src/integrations/gpt/script_guard'; +import { createGptDiagnosticsIntegrationRegistration } from '../../src/integrations/gpt_diagnostics/module'; import { createPrebidIntegrationRegistration } from '../../src/integrations/prebid/module'; import { publicLog } from '../../src/kernel/fallback'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; @@ -63,6 +65,7 @@ function synchronousGptAdapter() { const targeting = new WeakMap>(); const bindingToken = Object.freeze({}); const refresh = vi.fn(); + let diagnosticsObserver: GoogletagDiagnosticsObserver | undefined; const facade: GoogletagFacade = Object.freeze({ bindingToken: () => bindingToken, clearTargeting: vi.fn((slot: object, key?: string) => { @@ -96,6 +99,13 @@ function synchronousGptAdapter() { bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observeDiagnostics: (observer: GoogletagDiagnosticsObserver) => { + if (diagnosticsObserver) return undefined; + diagnosticsObserver = observer; + return () => { + if (diagnosticsObserver === observer) diagnosticsObserver = undefined; + }; + }, observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => Value) => { let result: Promise; @@ -110,8 +120,25 @@ function synchronousGptAdapter() { return { adapter, emit: (eventType: string, event: unknown): void => { - for (const listener of listeners.get(eventType) ?? []) listener(event); + for (const listener of listeners.get(eventType) ?? []) { + listener(event); + if (typeof event !== 'object' || event === null || !('slot' in event)) continue; + diagnosticsObserver?.( + Object.freeze({ + ...event, + kind: eventType, + slot: event.slot, + }) as Parameters[0] + ); + } }, + diagnosticsObserverActive: () => diagnosticsObserver !== undefined, + listenerInventory: () => + Object.freeze( + [...listeners.entries()] + .filter(([, registered]) => registered.size > 0) + .map(([eventType, registered]) => Object.freeze([eventType, registered.size] as const)) + ), refresh, }; } @@ -521,6 +548,89 @@ describe('browser composition', () => { expect(listener).not.toHaveBeenCalled(); }); + 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 composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: active ? [{ id: 'gpt_diagnostics', required: true }] : [], + }, + knownIntegrationIds: active ? Object.freeze(['gpt_diagnostics']) : Object.freeze([]), + boot: { + 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 } }, + }, + 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( + 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'] + ); + + 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('activates reversible core effects in exact order and disposes them in reverse', async () => { const target = {}; const order: string[] = []; @@ -635,7 +745,7 @@ describe('browser composition', () => { expect(diagnostics?.renderTrace?.history()).toEqual([]); }); - it('starts slot listeners before post-commit GPT startup and disposes both listeners', async () => { + it('starts core slot listeners before module activation and disposes both listeners', async () => { const releaseId = 'a'.repeat(64); const subscriptions: string[] = []; const releases: string[] = []; @@ -650,6 +760,7 @@ describe('browser composition', () => { bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observeDiagnostics: () => vi.fn(), observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => T) => { const result = Promise.resolve(command(facade)); @@ -662,7 +773,7 @@ describe('browser composition', () => { _adapters: unknown, services: { readonly slots: { readonly snapshotForTest: () => { records: number } } } ) => { - expect(subscriptions).toEqual([]); + expect(subscriptions).toEqual(['slotRequested', 'slotRenderEnded']); expect(services.slots.snapshotForTest().records).toBe(0); } ); @@ -715,6 +826,90 @@ describe('browser composition', () => { 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: { + version: 1, + releaseId, + integrations: [{ id: 'gpt_diagnostics', required: true }], + }, + knownIntegrationIds: Object.freeze(['gpt_diagnostics']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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( + 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('injects GPT and Prebid module boundaries with only server-frozen configuration', async () => { const releaseId = 'a'.repeat(64); const target = {}; 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 index 1770c5613..95c0667d9 100644 --- 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 @@ -222,7 +222,6 @@ test('permits external-global ownership only in adapter source files', () => { test('temporary allowlists are exact, narrow, and inventoried for Task 22 removal', () => { assert.deepEqual(LEGACY_ADTECH_GLOBAL_ALLOWLIST, [ 'src/integrations/gpt/index.ts', - 'src/integrations/gpt_diagnostics/observer.ts', 'src/integrations/prebid/index.ts', ]); assert.deepEqual(LEGACY_RESTRICTED_IMPORT_ALLOWLIST, [ 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 index 085d89e0e..dd21c36b8 100644 --- 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 @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import type { GoogletagAdapter, @@ -16,6 +16,12 @@ function fact(index: number): Readonly { } describe('GPT diagnostics fact transport', () => { + 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); @@ -95,10 +101,23 @@ describe('GPT diagnostics fact transport', () => { expect(subscriptions.sort()).toEqual( ['impressionViewable', 'slotOnload', 'slotResponseReceived', 'slotVisibilityChanged'].sort() ); - dispose(); - dispose(); + 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(); + }); }); 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 5badf7638..8f9ebc7f2 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,10 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { LegacyTsjsApi } 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/integrations/gpt_diagnostics'; import { GPT_DIAGNOSTICS_HOST_ID } from '../../../src/integrations/gpt_diagnostics/overlay'; interface FakeSlot { @@ -12,58 +10,19 @@ interface FakeSlot { getAdUnitPath(): string; } -type Listener = (event: unknown) => void; - -type DiagnosticsTestWindow = NonNullable[0]>; - -const target = window as unknown as DiagnosticsTestWindow; - -function coreApi(): LegacyTsjsApi { - 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); - }); - 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 { + return Object.freeze({ getSlotElementId: () => id, getAdUnitPath: () => `/example/site/${id}`, - }; + }); } -async function settle(): Promise { - await Promise.resolve(); - await Promise.resolve(); +function fact( + kind: GoogletagDiagnosticsFact['kind'], + observedSlot: object, + fields: Partial = {} +): Readonly { + return Object.freeze({ kind, slot: observedSlot, ...fields }); } beforeEach(() => { @@ -77,155 +36,87 @@ 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; - - expect(isGptDiagnosticsActive(target)).toBe(false); - expect(installGptDiagnosticsRuntime(target)).toBeUndefined(); +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(target.tsjs?.gptDiagnostics).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 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); - 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() + const release = runtime.activate(); + const api = runtime.currentApi(); + + 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, - }); - 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']); - }); + 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)); - it('leaves no half-initialized API when the core API is unavailable', () => { - target.__tsjs_gpt_diagnostics_active = true; - delete target.tsjs; + const secondRelease = runtime.activate(); - 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..a0b5cce11 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/module.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createGptDiagnosticsIntegrationRegistration } from '../../../src/integrations/gpt_diagnostics/module'; +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); + +function manifest() { + return { + version: 1, + releaseId: RELEASE_ID, + integrations: [{ id: 'gpt_diagnostics', required: true }], + }; +} + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +describe('transactional GPT diagnostics integration module', () => { + it('prepares inertly, activates before publication, and releases exactly once', async () => { + const order: string[] = []; + const release = vi.fn(() => order.push('release')); + const activate = vi.fn(() => { + order.push('diagnostics:activate'); + return release; + }); + const runtime = Object.freeze({ activate, currentApi: vi.fn() }); + const registry = createIntegrationRegistry({ + manifest: manifest(), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt_diagnostics']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({ active: true }), + interfaces: Object.freeze({ gpt_diagnostics: runtime }), + }), + }); + registry.register(createGptDiagnosticsIntegrationRegistration(RELEASE_ID)); + + const result = await registry.install(callbacks(order)); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['core', 'diagnostics:activate', 'publish', 'drain']); + expect(activate).toHaveBeenCalledOnce(); + if (result.state === 'kernel') { + result.dispose(); + result.dispose(); + } + expect(release).toHaveBeenCalledOnce(); + }); + + it.each([ + ['inactive', Object.freeze({ active: false })], + ['extra field', Object.freeze({ active: true, legacy: true })], + ['mutable', { active: true }], + ['missing', Object.freeze({})], + ])('rejects %s configuration without activating', async (_name, config) => { + const activate = vi.fn(() => vi.fn()); + const registry = createIntegrationRegistry({ + manifest: manifest(), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt_diagnostics']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ + gpt_diagnostics: Object.freeze({ activate, currentApi: vi.fn() }), + }), + }), + }); + registry.register(createGptDiagnosticsIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + }); + + it('rejects a forged composition runtime during inert preparation', async () => { + const registry = createIntegrationRegistry({ + manifest: manifest(), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt_diagnostics']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({ active: true }), + interfaces: Object.freeze({ + gpt_diagnostics: Object.freeze({ activate: vi.fn(), currentApi: vi.fn(), extra: true }), + }), + }), + }); + registry.register(createGptDiagnosticsIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + }); +}); 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 b1b8fc95d..ca934a177 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,12 @@ import { describe, expect, it, vi } from 'vitest'; +import type { GoogletagDiagnosticsFact } from '../../../src/adapters/googletag'; import { GptDiagnosticsObserver, type GptDiagnosticsObserverStore, } 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 { markGptObserved: vi.fn(), @@ -31,145 +20,51 @@ function fakeStore(): GptDiagnosticsObserverStore { } function fakeSlot(): GptDiagnosticsSlotLike { - return { + return Object.freeze({ 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); }); - 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: object, + fields: Partial = {} +): Readonly { + return Object.freeze({ kind, slot, ...fields }); } describe('GptDiagnosticsObserver', () => { - it('installs exactly the six documented listeners through googletag.cmd', () => { + it('starts exactly once without reading or mutating any browser global', () => { const store = fakeStore(); - const gpt = controlledGpt(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - - observer.install(); + const observer = new GptDiagnosticsObserver(store); - expect(gpt.googletag.cmd).toHaveLength(1); - expect(gpt.pubads.addEventListener).not.toHaveBeenCalled(); + observer.start(); + observer.start(); - 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); + expect(store.markGptObserved).toHaveBeenCalledOnce(); }); - it('is idempotent before and after command queue execution', () => { + it('consumes all six normalized adapter facts', () => { 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('creates a command queue and waits when GPT is absent', () => { - const store = fakeStore(); - const delayedWindow: { - googletag?: { - cmd: Array<() => void>; - pubads?: () => { addEventListener: (name: EventName, listener: EventListener) => void }; - }; - } = {}; - const observer = new GptDiagnosticsObserver(store, { window: delayedWindow }); - - observer.install(); - - expect(delayedWindow.googletag?.cmd).toHaveLength(1); - const gpt = controlledGpt(); - delayedWindow.googletag!.pubads = gpt.googletag.pubads; - delayedWindow.googletag!.cmd[0]!(); - - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); - }); - - it('preserves an already-loaded custom command push contract', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const callbacks: Array<() => void> = []; - const customPush = vi.fn((...next: Array<() => void>) => { - callbacks.push(...next); - for (const callback of next) callback(); - return callbacks.length; - }); - const observer = new GptDiagnosticsObserver(store, { - window: { - googletag: { - cmd: { push: customPush }, - pubads: gpt.googletag.pubads, - }, - }, - }); - - observer.install(); - - expect(customPush).toHaveBeenCalledTimes(1); - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); - }); - - it('normalizes allowed callback facts and forwards every event kind', () => { - 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 }); + 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); expect(store.recordSlotResponseReceived).toHaveBeenCalledWith(slot); expect(store.recordSlotRenderEnded).toHaveBeenCalledWith(slot, { @@ -183,98 +78,32 @@ describe('GptDiagnosticsObserver', () => { expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(slot, 42); }); - 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); }); - 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('does not patch GPT or browser methods', () => { - 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).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); }); }); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 0c8be81c8..54485e8b6 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -142,6 +142,7 @@ function createGptHarness( bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observeDiagnostics: () => vi.fn(), observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => T) => { let disposed = false; @@ -4297,6 +4298,7 @@ describe('Task 11 adversarial ownership review', () => { bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observeDiagnostics: () => vi.fn(), observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => T) => { let value: T; From 81765844df8c46810b74690a795666f8f1057dec Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:22:20 -0700 Subject: [PATCH 127/194] Move diagnostics session state to the server --- .../trusted-server-core/src/html_processor.rs | 20 +-- .../src/integrations/gpt_diagnostics.rs | 137 ++++++++++++++---- .../integrations/gpt_diagnostics_bootstrap.js | 63 +------- crates/trusted-server-core/src/publisher.rs | 118 ++++++++++++++- .../trusted-server-core/src/trace_cookie.rs | 59 +++++++- .../gpt_diagnostics/bootstrap.test.ts | 123 +--------------- 6 files changed, 293 insertions(+), 227 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 4c827ace0..6728f9364 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -348,12 +348,6 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso for insert in integrations.head_inserts(&ctx) { snippet.push_str(&insert); } - if let Some(bootstrap) = gpt_diagnostics - .as_ref() - .and_then(GptDiagnosticsRequestDecision::bootstrap_script) - { - snippet.push_str(&bootstrap); - } // Main bundle: core + non-deferred integrations (synchronous). let immediate_ids = integrations.js_module_ids_immediate(); snippet.push_str(&tsjs::tsjs_script_tag(&immediate_ids)); @@ -871,14 +865,13 @@ mod tests { .process(Cursor::new(html.as_bytes()), &mut output) .expect("should process HTML"); let processed = String::from_utf8(output).expect("should produce valid UTF-8"); - let bootstrap_marker = "__tsjs_gpt_diagnostics_active"; let bundle_marker = "id=\"trustedserver-js\""; let diagnostics_marker = "tsjs-gpt_diagnostics.min.js"; assert_eq!( - processed.matches(bootstrap_marker).count(), - 1, - "should inject the diagnostics bootstrap once" + processed.matches("__tsjs_gpt_diagnostics_active").count(), + 0, + "server boot data must be the only browser-visible activation result" ); assert_eq!( processed.matches(bundle_marker).count(), @@ -890,19 +883,12 @@ mod tests { 1, "should inject one standalone diagnostics module" ); - let bootstrap_index = processed - .find(bootstrap_marker) - .expect("should include diagnostics bootstrap"); let bundle_index = processed .find(bundle_marker) .expect("should include immediate TSJS bundle"); let diagnostics_index = processed .find(diagnostics_marker) .expect("should include standalone diagnostics module"); - assert!( - bootstrap_index < bundle_index, - "should activate before core executes" - ); assert!( bundle_index < diagnostics_index, "should load diagnostics after core" diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs index 350a09cf8..fd7ac0552 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs @@ -62,7 +62,7 @@ pub enum GptDiagnosticsCookieAction { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct GptDiagnosticsRequestDecision { active: bool, - clean_browser_path_and_query: Option, + reserved_directive: bool, cookie_action: GptDiagnosticsCookieAction, } @@ -73,33 +73,22 @@ impl GptDiagnosticsRequestDecision { self.active } + /// Serialize the exact `DiagnosticsBootV1.gpt` value for the boot emitter. + #[must_use] + pub fn boot_config_json(&self) -> &'static str { + if self.active { + r#"{"active":true}"# + } else { + r#"{"active":false}"# + } + } + /// Whether the response must be private and non-storeable. #[must_use] pub fn requires_private_no_store(&self) -> bool { self.active || self.cookie_action != GptDiagnosticsCookieAction::None - || self.clean_browser_path_and_query.is_some() - } - - /// Build the early activation/URL-cleanup bootstrap for an HTML document. - #[must_use] - pub fn bootstrap_script(&self) -> Option { - if !self.active && self.clean_browser_path_and_query.is_none() { - return None; - } - - let mut script = String::from(""); - Some(script) + || self.reserved_directive } /// Build the synchronous standalone diagnostics module tag. @@ -183,9 +172,11 @@ pub fn prepare_request( replace_path_and_query(request, &clean_path)?; } - let mut decision = GptDiagnosticsRequestDecision::default(); + let mut decision = GptDiagnosticsRequestDecision { + reserved_directive: had_reserved_query, + ..GptDiagnosticsRequestDecision::default() + }; if integration_enabled && eligible_navigation && had_reserved_query { - decision.clean_browser_path_and_query = Some(clean_path); match directive { QueryDirective::Enable => { decision.active = true; @@ -224,6 +215,7 @@ pub fn finalize_response( decision: &GptDiagnosticsRequestDecision, response: &mut Response, ) { + sanitize_console_set_cookie(response); let cookie = match decision.cookie_action { GptDiagnosticsCookieAction::None => None, GptDiagnosticsCookieAction::SetSession => { @@ -284,9 +276,9 @@ fn console_cookie_state(request: &Request) -> ConsoleCookieState { for cookie in value.split(';') { let cookie = cookie.trim(); match cookie.split_once('=') { - Some((name, value)) if name.trim() == GPT_DIAGNOSTICS_COOKIE => { + Some((name, value)) if name == GPT_DIAGNOSTICS_COOKIE => { state.occurrences += 1; - state.canonical |= value.trim() == "1"; + state.canonical |= value == "1"; } None if cookie == GPT_DIAGNOSTICS_COOKIE => state.occurrences += 1, _ => {} @@ -296,6 +288,33 @@ fn console_cookie_state(request: &Request) -> ConsoleCookieState { state } +fn sanitize_console_set_cookie(response: &mut Response) { + let retained = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter(|value| { + let pair = value + .as_bytes() + .split(|byte| *byte == b';') + .next() + .unwrap_or_default(); + let name = pair + .split(|byte| *byte == b'=') + .next() + .unwrap_or_default() + .trim_ascii(); + name != GPT_DIAGNOSTICS_COOKIE.as_bytes() + }) + .cloned() + .collect::>(); + + response.headers_mut().remove(header::SET_COOKIE); + for value in retained { + response.headers_mut().append(header::SET_COOKIE, value); + } +} + fn sanitize_console_cookie(request: &mut Request) { let retained = request .headers() @@ -406,9 +425,7 @@ mod tests { "https://publisher.example/page?keep=%2F" ); assert_eq!(request.headers()[header::COOKIE], "other=value"); - let bootstrap = decision.bootstrap_script().expect("should bootstrap"); - assert!(bootstrap.contains("__tsjs_gpt_diagnostics_active=true")); - assert!(bootstrap.contains("/page?keep=%2F")); + assert_eq!(decision.boot_config_json(), r#"{"active":true}"#); } #[test] @@ -445,6 +462,13 @@ mod tests { let decision = prepare_request(&settings(true), &mut duplicate).expect("should prepare"); assert!(!decision.active()); assert_eq!(duplicate.headers()[header::COOKIE], "other=value"); + + for noncanonical in ["__Host-ts-console =1", "__Host-ts-console= 1"] { + let mut request = navigation("https://publisher.example/page", Some(noncanonical)); + let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + assert!(!decision.active(), "{noncanonical} must fail closed"); + assert!(!request.headers().contains_key(header::COOKIE)); + } } #[test] @@ -476,6 +500,46 @@ mod tests { ); } + #[test] + fn ts_console_invalid_directive_is_private_without_mutating_the_session() { + let mut request = navigation( + "https://publisher.example/page?keep=1&ts_console=True", + Some("__Host-ts-console=1"), + ); + let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + let mut response = Response::builder() + .header(header::CACHE_CONTROL, "public, max-age=60") + .body(EdgeBody::empty()) + .expect("should build response"); + + finalize_response(&decision, &mut response); + + assert!(!decision.active()); + assert_eq!(request.uri().query(), Some("keep=1")); + assert!(!response.headers().contains_key(header::SET_COOKIE)); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + } + + #[test] + fn ts_console_is_disabled_by_default_while_reserved_input_is_still_sanitized() { + let mut request = navigation( + "https://publisher.example/page?keep=1&ts_console=1", + Some("__Host-ts-console=1; other=value"), + ); + + let decision = prepare_request(&settings(false), &mut request).expect("should prepare"); + + assert!(!decision.active()); + assert_eq!(decision.boot_config_json(), r#"{"active":false}"#); + assert_eq!(decision.cookie_action, GptDiagnosticsCookieAction::None); + assert_eq!(request.uri().query(), Some("keep=1")); + assert_eq!(request.headers()[header::COOKIE], "other=value"); + assert!(decision.requires_private_no_store()); + } + #[test] fn ts_console_finalization_sets_cookie_and_strips_shared_cache_headers() { let mut request = navigation("https://publisher.example/?ts_console=1", None); @@ -485,6 +549,11 @@ mod tests { .header("surrogate-control", "max-age=60") .header("fastly-surrogate-control", "max-age=60") .header("cloudflare-cdn-cache-control", "public, max-age=60") + .header(header::SET_COOKIE, "publisher=value; Path=/") + .header( + header::SET_COOKIE, + "__Host-ts-console=origin; Path=/; Secure", + ) .body(EdgeBody::empty()) .expect("should build response"); @@ -494,7 +563,13 @@ mod tests { response.headers()[header::CACHE_CONTROL], "private, no-store" ); - assert_eq!(response.headers()[header::SET_COOKIE], SET_CONSOLE_COOKIE); + let cookies = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should emit valid cookie text")) + .collect::>(); + assert_eq!(cookies, vec!["publisher=value; Path=/", SET_CONSOLE_COOKIE]); assert!(!response.headers().contains_key("surrogate-control")); assert!(!response.headers().contains_key("fastly-surrogate-control")); assert!( diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js index a857fc984..dde2197f8 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js @@ -1,59 +1,4 @@ -// Early activation bootstrap for the GPT diagnostics integration. -// -// This script intentionally owns only tab-local activation and one-time URL -// cleanup. The TypeScript integration reads the document flag below and owns -// all GPT observation, storage, API, and presentation behavior. -(function () { - if (typeof window === "undefined") return; - - var queryName = "ts_console"; - var storageKey = "tsjs:gptDiagnostics:active"; - var activeFlag = "__tsjs_gpt_diagnostics_active"; - var active = false; - var directiveRecognized = false; - var url; - - try { - url = new URL(window.location.href); - var value = url.searchParams.get(queryName); - - if (value === "1" || value === "true") { - active = true; - directiveRecognized = true; - } else if (value === "0" || value === "false") { - active = false; - directiveRecognized = true; - } - } catch (_) { - url = undefined; - } - - if (directiveRecognized) { - try { - window.sessionStorage.setItem(storageKey, active ? "1" : "0"); - } catch (_) { - // The recognized directive still applies to this document. - } - - if (url) { - url.searchParams.delete(queryName); - try { - window.history.replaceState( - window.history.state, - "", - url.pathname + url.search + url.hash, - ); - } catch (_) { - // URL cleanup is optional and must not block diagnostics activation. - } - } - } else { - try { - active = window.sessionStorage.getItem(storageKey) === "1"; - } catch (_) { - active = false; - } - } - - window[activeFlag] = active; -})(); +// GPT diagnostics activation is server-owned and is transported only through +// the validated, frozen diagnostics boot value. This intentionally has no +// browser-side activation behavior and remains only until the wiring cutover +// removes the superseded asset. diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index cd1852162..4595166dd 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2964,7 +2964,7 @@ pub async fn handle_publisher_request( .await; } - let response = Response::builder() + let mut response = Response::builder() .status(StatusCode::BAD_GATEWAY) .header(header::CACHE_CONTROL, "private, no-store") .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") @@ -2974,6 +2974,7 @@ pub async fn handle_publisher_request( .change_context(TrustedServerError::Proxy { message: "failed to build unexpected origin 304 response".to_string(), })?; + crate::integrations::gpt_diagnostics::finalize_response(&gpt_diagnostics, &mut response); return Ok(PublisherResponse::Buffered(response)); } @@ -5182,8 +5183,8 @@ mod tests { let html = String::from_utf8(output).expect("should produce UTF-8 HTML"); assert!( - html.contains("__tsjs_gpt_diagnostics_active"), - "should inject the activation flag" + !html.contains("__tsjs_gpt_diagnostics_active"), + "should not inject the removed activation flag" ); assert!( html.contains("tsjs-gpt_diagnostics.min.js"), @@ -5873,6 +5874,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 @@ -5986,6 +6035,69 @@ 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 publisher_origin_fetch_leaves_stream_response_disabled_when_unsupported() { let settings = create_test_settings(); 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-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts index 79fea5f0a..c40af3e56 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts @@ -1,127 +1,18 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; const bootstrapPath = resolve( process.cwd(), '../../trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js' ); const bootstrapSource = readFileSync(bootstrapPath, 'utf8'); -const storageKey = 'tsjs:gptDiagnostics:active'; - -type BootstrapWindow = Window & { - __tsjs_gpt_diagnostics_active?: boolean; -}; - -function runBootstrap(): void { - window.eval(bootstrapSource); -} - -function setUrl(url: string): void { - window.history.replaceState({ fixture: true }, '', url); -} - -function activeFlag(): boolean | undefined { - return (window as BootstrapWindow).__tsjs_gpt_diagnostics_active; -} - -describe('GPT diagnostics activation bootstrap', () => { - beforeEach(() => { - vi.restoreAllMocks(); - window.sessionStorage.clear(); - delete (window as BootstrapWindow).__tsjs_gpt_diagnostics_active; - setUrl('/article?existing=1#section'); - }); - - it.each(['1', 'true'])('activates the current tab for %s', (value) => { - setUrl(`/article?existing=1&ts_console=${value}#section`); - - runBootstrap(); - - expect(activeFlag()).toBe(true); - expect(window.sessionStorage.getItem(storageKey)).toBe('1'); - expect(window.location.pathname).toBe('/article'); - expect(window.location.search).toBe('?existing=1'); - expect(window.location.hash).toBe('#section'); - expect(window.history.state).toEqual({ fixture: true }); - }); - - it.each(['0', 'false'])('deactivates the current tab for %s', (value) => { - window.sessionStorage.setItem(storageKey, '1'); - setUrl(`/article?ts_console=${value}&existing=1#section`); - - runBootstrap(); - - expect(activeFlag()).toBe(false); - expect(window.sessionStorage.getItem(storageKey)).toBe('0'); - expect(window.location.search).toBe('?existing=1'); - expect(window.location.hash).toBe('#section'); - }); - - it('restores activation from session storage without a directive', () => { - window.sessionStorage.setItem(storageKey, '1'); - - runBootstrap(); - - expect(activeFlag()).toBe(true); - expect(window.location.search).toBe('?existing=1'); - }); - - it('ignores case variants and leaves the directive visible', () => { - window.sessionStorage.setItem(storageKey, '1'); - setUrl('/article?ts_console=True&existing=1#section'); - - runBootstrap(); - - expect(activeFlag()).toBe(true); - expect(window.location.search).toBe('?ts_console=True&existing=1'); - }); - - it('applies a recognized directive to the current document when storage throws', () => { - vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new Error('storage unavailable'); - }); - setUrl('/article?ts_console=true'); - - expect(() => runBootstrap()).not.toThrow(); - expect(activeFlag()).toBe(true); - expect(window.location.search).toBe(''); - }); - - it('keeps activation when URL cleanup throws', () => { - setUrl('/article?ts_console=true&existing=1#section'); - vi.spyOn(window.history, 'replaceState').mockImplementation(() => { - throw new Error('history unavailable'); - }); - - expect(() => runBootstrap()).not.toThrow(); - expect(activeFlag()).toBe(true); - expect(window.sessionStorage.getItem(storageKey)).toBe('1'); - expect(window.location.search).toBe('?ts_console=true&existing=1'); - }); - - it('removes every activation parameter after recognizing the first value', () => { - setUrl('/article?ts_console=true&existing=1&ts_console=false#section'); - - runBootstrap(); - - expect(activeFlag()).toBe(true); - expect(window.location.search).toBe('?existing=1'); - }); - - it('cleans a recognized directive only once across repeated execution', () => { - const nativeReplaceState = window.history.replaceState.bind(window.history); - const replaceState = vi - .spyOn(window.history, 'replaceState') - .mockImplementation((data, unused, url) => nativeReplaceState(data, unused, url)); - setUrl('/article?ts_console=true&existing=1#section'); - replaceState.mockClear(); - - runBootstrap(); - runBootstrap(); - - expect(activeFlag()).toBe(true); - expect(replaceState).toHaveBeenCalledTimes(1); +describe('GPT diagnostics activation ownership', () => { + it('leaves no browser-owned query, storage, history, or activation-flag bootstrap', () => { + expect(bootstrapSource).not.toMatch(/ts_console/); + expect(bootstrapSource).not.toMatch(/sessionStorage|localStorage/); + expect(bootstrapSource).not.toMatch(/replaceState/); + expect(bootstrapSource).not.toMatch(/__tsjs_gpt_diagnostics_active/); }); }); From f91f9d8405284a3b4993a49a6cb7a0d9220c225d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:37:07 -0700 Subject: [PATCH 128/194] Complete runtime render trace diagnostics --- .../trusted-server-js/lib/src/core/trace.ts | 297 +++++++++++++++++- .../lib/src/services/render.ts | 36 ++- .../lib/test/core/trace_runtime.test.ts | 185 ++++++++++- .../lib/test/services/render.test.ts | 25 ++ 4 files changed, 537 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 456902f0e..20a99a20f 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -101,7 +101,7 @@ 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'; + if (record.visible !== true) 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 @@ -568,9 +568,13 @@ export interface RenderTraceRuntimeScheduler { } export interface RenderTraceRuntimeOptions { + readonly document?: Document | undefined; + readonly exportRecord?: (record: Readonly) => void; readonly now?: () => number; readonly onOverflow?: (droppedNotifications: number) => void; + readonly onPresentationError?: (error: unknown) => void; readonly onSubscriberError?: (error: unknown) => void; + readonly overlayEnabled?: boolean; readonly schedule?: (callback: () => void) => () => void; readonly scheduler?: RenderTraceRuntimeScheduler; } @@ -642,6 +646,287 @@ function scheduleRenderTraceTask(callback: () => void): () => void { return (): void => globalThis.clearTimeout(handle); } +const RUNTIME_TRACE_ATTRIBUTES = [ + 'data-ts-slot-id', + 'data-ts-render-path', + 'data-ts-rendered', + 'data-ts-auction-id', + 'data-ts-bidder', + 'data-ts-ad-id', + 'data-ts-bid-id', + 'data-ts-creative-id', + 'data-ts-adm-hash', + 'data-ts-served-from', + 'data-ts-gam-empty', + 'data-ts-injected', + 'data-ts-visible', +] as const; + +interface PresentedTraceSlot { + readonly element: HTMLElement; + readonly priorInlinePosition?: string; +} + +interface RenderTracePresentation { + readonly present: (record: Readonly) => void; + readonly prune: (slotId: string) => void; + readonly dispose: () => void; +} + +function createRenderTracePresentation( + options: RenderTraceRuntimeOptions, + history: () => readonly Readonly[] +): RenderTracePresentation { + const targetDocument = + options.document ?? (typeof document === 'undefined' ? undefined : document); + const overlayEnabled = options.overlayEnabled === true; + const presented = new Map(); + const panelRecords = new Map>(); + const panelRows = new Map(); + let panel: HTMLElement | undefined; + let panelHeading: HTMLElement | undefined; + let panelRowsHost: HTMLElement | undefined; + + const report = (error: unknown): void => { + try { + options.onPresentationError?.(error); + } catch { + // Presentation reporting is diagnostics-only. + } + }; + + const removeBadge = (element: HTMLElement): void => { + for (const badge of element.querySelectorAll(`:scope > .${TRACE_BADGE_CLASS}`)) badge.remove(); + }; + + const clearElement = (presentedSlot: PresentedTraceSlot): void => { + const { element, priorInlinePosition } = presentedSlot; + for (const attribute of RUNTIME_TRACE_ATTRIBUTES) element.removeAttribute(attribute); + removeBadge(element); + if (priorInlinePosition !== undefined && element.style.position === 'relative') { + element.style.position = priorInlinePosition; + } + }; + + const createBadge = ( + element: HTMLElement, + record: Readonly + ): PresentedTraceSlot => { + let priorInlinePosition: string | undefined; + try { + const position = targetDocument?.defaultView?.getComputedStyle(element).position; + if (position === 'static' || position === '') { + priorInlinePosition = element.style.position; + element.style.position = 'relative'; + } + } catch { + // A badge remains noninteractive even if its containing block is publisher-owned. + } + const status = panelStatus(record as RenderRecord); + const style = STATUS_STYLE[status]; + const badge = targetDocument?.createElement('div'); + if (!badge) { + return { + element, + ...(priorInlinePosition === undefined ? {} : { priorInlinePosition }), + }; + } + badge.className = TRACE_BADGE_CLASS; + badge.textContent = + `TS ${style.mark} #${record.seq}` + + `${record.bidder ? ` · ${record.bidder}` : ''}` + + `${style.label === 'ok' ? '' : ` · ${style.label}`}`; + badge.style.setProperty('position', 'absolute'); + badge.style.setProperty('top', '4px'); + badge.style.setProperty('left', '4px'); + badge.style.setProperty('z-index', '2147483646'); + badge.style.setProperty('pointer-events', 'none'); + badge.style.setProperty('font', '10px/1.5 ui-monospace, Menlo, Consolas, monospace'); + badge.style.setProperty('padding', '1px 5px'); + badge.style.setProperty('color', '#fff'); + badge.style.setProperty('background', style.color); + badge.style.setProperty('border-radius', '3px'); + element.appendChild(badge); + return { element, ...(priorInlinePosition === undefined ? {} : { priorInlinePosition }) }; + }; + + const exportRow = (record: Readonly): void => { + const copied = copyRenderTraceRecord(record); + try { + if (options.exportRecord) { + options.exportRecord(copied); + return; + } + const clipboard = targetDocument?.defaultView?.navigator.clipboard; + const write = clipboard?.writeText; + if (typeof write !== 'function') return; + const pending = Reflect.apply(write, clipboard, [JSON.stringify(copied, null, 2)]) as + Promise | undefined; + void pending?.catch(report); + } catch (error) { + report(error); + } + }; + + const renderPanel = (record?: Readonly): void => { + if (!overlayEnabled || !targetDocument?.body) return; + if (!panel) { + const collision = targetDocument.getElementById(TRACE_PANEL_ID); + if (collision) return; + panel = targetDocument.createElement('div'); + panel.id = TRACE_PANEL_ID; + panel.setAttribute('data-ts-render-trace-owner', '1'); + panel.style.setProperty('position', 'fixed'); + panel.style.setProperty('bottom', '12px'); + panel.style.setProperty('right', '12px'); + panel.style.setProperty('z-index', '2147483647'); + panel.style.setProperty('max-width', '360px'); + panel.style.setProperty('max-height', '45vh'); + panel.style.setProperty('overflow', 'auto'); + panel.style.setProperty('background', 'rgba(17,17,17,0.94)'); + panel.style.setProperty('color', '#eee'); + panel.style.setProperty('font', '11px/1.5 ui-monospace, Menlo, Consolas, monospace'); + panel.style.setProperty('border', '1px solid #333'); + panel.style.setProperty('border-radius', '6px'); + panel.style.setProperty('box-shadow', '0 4px 16px rgba(0,0,0,0.4)'); + panelHeading = targetDocument.createElement('div'); + panelHeading.style.setProperty('padding', '6px 10px'); + panelHeading.style.setProperty('font-weight', '700'); + panelRowsHost = targetDocument.createElement('div'); + panel.append(panelHeading, panelRowsHost); + targetDocument.body.appendChild(panel); + } + const retained = history(); + panelHeading!.textContent = `TS Render Trace · ${retained.length} renders`; + const retainedSequences = new Set(retained.map(({ seq }) => seq)); + for (const [sequence, row] of panelRows) { + if (retainedSequences.has(sequence)) continue; + row.remove(); + panelRows.delete(sequence); + panelRecords.delete(sequence); + } + if (record && retainedSequences.has(record.seq)) { + panelRecords.set(record.seq, record); + let row = panelRows.get(record.seq); + if (!row) { + row = targetDocument.createElement('button'); + row.type = 'button'; + row.setAttribute('data-ts-trace-seq', String(record.seq)); + row.style.setProperty('display', 'block'); + row.style.setProperty('width', '100%'); + row.style.setProperty('padding', '6px 10px'); + row.style.setProperty('border', '0'); + row.style.setProperty('border-top', '1px solid #2a2a2a'); + row.style.setProperty('background', 'transparent'); + row.style.setProperty('font', 'inherit'); + row.style.setProperty('text-align', 'left'); + row.style.setProperty('cursor', 'pointer'); + row.addEventListener('click', () => { + const exported = panelRecords.get(record.seq); + if (exported) exportRow(exported); + }); + panelRows.set(record.seq, row); + panelRowsHost!.prepend(row); + } + const status = panelStatus(record as RenderRecord); + const style = STATUS_STYLE[status]; + row.textContent = `#${record.seq} ${style.mark} ${record.slotId} · ${style.label} · ${record.path}`; + row.style.setProperty('border-left', `3px solid ${style.color}`); + row.style.setProperty('color', style.color); + } + }; + + const present = (record: Readonly): void => { + try { + const prior = presented.get(record.slotId); + const elementId = record.elementId ?? record.slotId; + const candidate = targetDocument?.getElementById(elementId); + const element = candidate && candidate instanceof HTMLElement ? candidate : undefined; + if (prior && prior.element !== element) { + clearElement(prior); + presented.delete(record.slotId); + } + if (element) { + const retainedPosition = prior?.element === element ? prior.priorInlinePosition : undefined; + removeBadge(element); + const values: Readonly< + Record<(typeof RUNTIME_TRACE_ATTRIBUTES)[number], 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), + }; + for (const attribute of RUNTIME_TRACE_ATTRIBUTES) { + const value = values[attribute]; + if (value === undefined || value === '') element.removeAttribute(attribute); + else element.setAttribute(attribute, value); + } + const status = panelStatus(record as RenderRecord); + if ( + overlayEnabled && + element.tagName !== 'IFRAME' && + (status === 'ok' || status === 'gam-only') + ) { + const next = createBadge(element, record); + presented.set(record.slotId, { + element, + ...(retainedPosition === undefined + ? next.priorInlinePosition === undefined + ? {} + : { priorInlinePosition: next.priorInlinePosition } + : { priorInlinePosition: retainedPosition }), + }); + } else { + if (retainedPosition !== undefined && element.style.position === 'relative') { + element.style.position = retainedPosition; + } + presented.set(record.slotId, { element }); + } + } + renderPanel(record); + } catch (error) { + report(error); + } + }; + + const prune = (slotId: string): void => { + try { + const existing = presented.get(slotId); + if (existing) clearElement(existing); + presented.delete(slotId); + renderPanel(); + } catch (error) { + report(error); + } + }; + + const dispose = (): void => { + for (const slotId of [...presented.keys()]) prune(slotId); + try { + panel?.remove(); + } catch (error) { + report(error); + } + panel = undefined; + panelHeading = undefined; + panelRowsHost = undefined; + panelRecords.clear(); + panelRows.clear(); + }; + + return Object.freeze({ present, prune, dispose }); +} + /** Create one document-runtime render trace without exposing its mutation authority. */ export function createRenderTraceDiagnostics( options: RenderTraceRuntimeOptions = {} @@ -658,6 +943,7 @@ export function createRenderTraceDiagnostics( let reportedDroppedNotifications = 0; let cancelScheduled: (() => void) | undefined; let disposed = false; + const presentation = createRenderTracePresentation(options, () => history); const schedule = (callback: () => void): (() => void) => { if (options.schedule) return options.schedule(callback); @@ -760,7 +1046,10 @@ export function createRenderTraceDiagnostics( if (disposed) return committed; if (!previous && current.size >= MAX_RENDER_TRACE_SLOTS) { const oldestSlot = current.keys().next().value as string | undefined; - if (oldestSlot !== undefined) current.delete(oldestSlot); + if (oldestSlot !== undefined) { + current.delete(oldestSlot); + presentation.prune(oldestSlot); + } } current.set(committed.slotId, committed); recordsBySequence.set(committed.seq, committed); @@ -771,6 +1060,7 @@ export function createRenderTraceDiagnostics( } if (previous && !retained(previous)) recordsBySequence.delete(previous.seq); enqueue(committed); + presentation.present(committed); return committed; }; @@ -811,6 +1101,7 @@ export function createRenderTraceDiagnostics( const historyIndex = history.findIndex(({ seq }) => seq === targetSequence); if (historyIndex >= 0) history[historyIndex] = committed; enqueue(committed); + presentation.present(committed); return committed; }; @@ -822,6 +1113,7 @@ export function createRenderTraceDiagnostics( } current.delete(slotId); if (!retained(existing)) recordsBySequence.delete(existing.seq); + presentation.prune(slotId); return true; }; @@ -874,6 +1166,7 @@ export function createRenderTraceDiagnostics( current.clear(); history.length = 0; recordsBySequence.clear(); + presentation.dispose(); }; return Object.freeze({ api, diagnostics: api, record, enrich, prune, dispose }); diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index 417bff92e..5d9c059c8 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -642,8 +642,12 @@ export interface RenderAttemptOptions { } export interface RenderAttemptDiagnosticsObservation extends Readonly> { + readonly adId?: string; readonly kind: 'render_attempt'; readonly attemptId: string; + readonly bidId?: string; + readonly creativeId?: string; + readonly injected: boolean; readonly slotId: string; readonly path: 'auction' | 'ssat'; readonly rendered: boolean; @@ -1558,19 +1562,45 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp } } if (publishDiagnostics) { + const accepted = terminal.outcome === 'accepted'; const servedFrom = - terminal.outcome === 'accepted' && terminalRenderSource?.type === 'cache' + accepted && terminalRenderSource?.type === 'cache' ? ('pbs-cache' as const) - : terminal.outcome === 'accepted' + : accepted ? ('inline' as const) : undefined; + let sourceIdentity: Readonly<{ adId?: string; bidId?: string; creativeId?: string }> = + Object.freeze({}); + if (accepted && terminalRenderSource) { + try { + const readString = (name: string): string | undefined => { + const descriptor = Object.getOwnPropertyDescriptor(terminalRenderSource, name); + return descriptor && 'value' in descriptor && typeof descriptor.value === 'string' + ? descriptor.value + : undefined; + }; + const bidId = terminalRenderSource.type === 'aps' ? readString('bidId') : undefined; + const creativeId = + terminalRenderSource.type === 'aps' ? readString('creativeId') : undefined; + const adId = terminalRenderSource.type === 'cache' ? readString('cacheId') : undefined; + sourceIdentity = frozen({ + ...(adId === undefined ? {} : { adId }), + ...(bidId === undefined ? {} : { bidId }), + ...(creativeId === undefined ? {} : { creativeId }), + }); + } catch { + // Optional trace identity cannot affect the committed terminal state. + } + } const observation = frozen({ kind: 'render_attempt', attemptId: id, slotId: slot, path: history.includes('waiting_for_gam_and_claim') ? 'ssat' : 'auction', - rendered: terminal.outcome === 'accepted', + rendered: accepted, + injected: accepted, ...(servedFrom === undefined ? {} : { servedFrom }), + ...sourceIdentity, state: terminal.outcome, outcome: terminal, }); 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 index fef447bc5..0f1f9da8c 100644 --- a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { createRenderTrace, DiagnosticsSubscriberLimitError } from '../../src/core/trace'; +import { + createRenderTrace, + DiagnosticsSubscriberLimitError, + TRACE_BADGE_CLASS, + TRACE_PANEL_ID, +} from '../../src/core/trace'; function harness() { const tasks: Array<() => void> = []; @@ -198,4 +203,182 @@ describe('render trace diagnostics runtime', () => { 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 = createRenderTrace({ 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')).toBe('true'); + 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 = createRenderTrace({ 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 = createRenderTrace({ 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 = createRenderTrace({ 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 = createRenderTrace({ 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 = createRenderTrace({ 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 owner = createRenderTrace({ document, overlayEnabled: true, exportRecord }); + for (let index = 1; index <= 201; index += 1) { + owner.record({ slotId: `slot-${index}`, path: 'auction', rendered: true }); + } + + 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 = createRenderTrace({ + 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/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index 028d0b47c..b08aee273 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -4577,12 +4577,37 @@ describe('RenderAttempt diagnostics producer', () => { 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[] = []; From 328f5cd7051348595d51ae8a5df4c036b25fc757 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:38:05 -0700 Subject: [PATCH 129/194] Prepare remaining integration lifecycles --- .../lib/src/integrations/datadome/module.ts | 53 ++ .../lib/src/integrations/didomi/module.ts | 145 +++++ .../integrations/google_tag_manager/module.ts | 78 +++ .../lib/src/integrations/lockr/module.ts | 132 +++++ .../src/integrations/osano/consent_mirror.ts | 539 ++++++++++++++++++ .../lib/src/integrations/osano/index.ts | 518 +---------------- .../lib/src/integrations/osano/module.ts | 49 ++ .../lib/src/integrations/permutive/module.ts | 210 +++++++ .../sourcepoint/consent_mirror.ts | 299 ++++++++++ .../lib/src/integrations/sourcepoint/index.ts | 302 +--------- .../src/integrations/sourcepoint/module.ts | 106 ++++ .../lib/src/integrations/testlight/module.ts | 224 ++++++++ .../lib/src/kernel/lifecycle_module.ts | 132 +++++ .../test/integrations/datadome/module.test.ts | 120 ++++ .../test/integrations/didomi/module.test.ts | 81 +++ .../google_tag_manager/module.test.ts | 100 ++++ .../integrations/lifecycle_modules.test.ts | 117 ++++ .../test/integrations/lockr/module.test.ts | 87 +++ .../lib/test/integrations/osano/index.test.ts | 35 +- .../test/integrations/osano/module.test.ts | 37 ++ .../integrations/permutive/module.test.ts | 123 ++++ .../integrations/sourcepoint/module.test.ts | 68 +++ .../integrations/testlight/module.test.ts | 104 ++++ .../lib/test/kernel/lifecycle_module.test.ts | 94 +++ 24 files changed, 2950 insertions(+), 803 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/datadome/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/didomi/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/google_tag_manager/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/lockr/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/osano/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/permutive/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_mirror.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/sourcepoint/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/testlight/module.ts create mode 100644 crates/trusted-server-js/lib/src/kernel/lifecycle_module.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/datadome/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/didomi/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/google_tag_manager/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/lifecycle_modules.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/osano/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/permutive/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/sourcepoint/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts 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..6351d985c --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/datadome/module.ts @@ -0,0 +1,53 @@ +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, { + validateConfig: (candidate) => candidate === undefined, + }); +} 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..871bd9c51 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/didomi/module.ts @@ -0,0 +1,145 @@ +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, { + validateConfig: didomiBootConfig, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/google_tag_manager/module.ts b/crates/trusted-server-js/lib/src/integrations/google_tag_manager/module.ts new file mode 100644 index 000000000..21f28cc6c --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/google_tag_manager/module.ts @@ -0,0 +1,78 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; +import { log } from '../../core/log'; + +import { + installGtmBeaconGuard, + installGtmGuard, + resetBeaconGuardState, + resetGuardState, +} from './script_guard'; + +export const GOOGLE_TAG_MANAGER_INTEGRATION_ID = 'google_tag_manager' as const; + +export interface GoogleTagManagerRuntimeDependencies { + readonly installBeaconGuard: () => void; + readonly installScriptGuard: () => void; + readonly resetBeaconGuard: () => void; + readonly resetScriptGuard: () => void; + readonly started: () => void; +} + +/** Own the reversible GTM script/preload and GA network guards for one runtime. */ +export function createGoogleTagManagerRuntime( + dependencies: GoogleTagManagerRuntimeDependencies = { + installBeaconGuard: installGtmBeaconGuard, + installScriptGuard: installGtmGuard, + resetBeaconGuard: resetBeaconGuardState, + resetScriptGuard: resetGuardState, + started: () => log.info('Google Tag Manager integration initialized'), + } +): IntegrationLifecycleRuntime { + return Object.freeze({ + activate: (_config: unknown) => { + let beaconAttempted = false; + try { + dependencies.installScriptGuard(); + beaconAttempted = true; + dependencies.installBeaconGuard(); + } catch (error) { + if (beaconAttempted) { + try { + dependencies.resetBeaconGuard(); + } catch { + // Continue through independent script-guard rollback. + } + } + try { + dependencies.resetScriptGuard(); + } catch { + // Preserve the activation failure after best-effort rollback. + } + throw error; + } + let active = true; + return (): void => { + if (!active) return; + active = false; + try { + dependencies.resetBeaconGuard(); + } finally { + dependencies.resetScriptGuard(); + } + }; + }, + start: (_config: unknown) => dependencies.started(), + }); +} + +export function createGoogleTagManagerIntegrationRegistration( + release: string +): IntegrationRegistration { + return createLifecycleIntegrationRegistration(GOOGLE_TAG_MANAGER_INTEGRATION_ID, release, { + validateConfig: (candidate) => candidate === undefined, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/lockr/module.ts b/crates/trusted-server-js/lib/src/integrations/lockr/module.ts new file mode 100644 index 000000000..0b91d31ed --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/lockr/module.ts @@ -0,0 +1,132 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; +import { log } from '../../core/log'; + +import { installLockrGuard, resetGuardState } from './script_guard'; + +export const LOCKR_INTEGRATION_ID = 'lockr' as const; + +interface LockrSdk { + host: string; +} + +export interface LockrRuntimeDependencies { + readonly clearTimeout: (timer: number) => void; + readonly getSdk: () => LockrSdk | undefined; + readonly installGuard: () => void; + readonly location: { readonly host: string; readonly protocol: string }; + readonly resetGuard: () => void; + readonly setTimeout: (callback: () => void, delay: number) => number; + readonly started: () => void; + readonly timedOut: () => void; +} + +/** Own the Lockr guard, bounded SDK readiness timer, and installed API host. */ +export function createLockrRuntime( + dependencies: LockrRuntimeDependencies = { + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => (globalThis as typeof globalThis & { identityLockr?: LockrSdk }).identityLockr, + installGuard: installLockrGuard, + location: window.location, + resetGuard: resetGuardState, + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: () => log.info('Lockr integration initialized'), + timedOut: () => log.warn('Lockr SDK not detected after', 2_500, 'ms'), + } +): IntegrationLifecycleRuntime { + let active = false; + let started = false; + let timer: number | undefined; + let installedHost: string | undefined; + let previousHost: string | undefined; + let ownedSdk: LockrSdk | undefined; + + const resetSdk = (): void => { + const sdk = ownedSdk; + const installed = installedHost; + const previous = previousHost; + ownedSdk = undefined; + installedHost = undefined; + previousHost = undefined; + if (!sdk || installed === undefined || previous === undefined) return; + try { + if (sdk.host === installed) sdk.host = previous; + } catch { + // Publisher replacement wins over cleanup. + } + }; + + return Object.freeze({ + activate: (_config: unknown): (() => void) => { + if (active) throw new Error('Lockr runtime is already active'); + try { + dependencies.installGuard(); + } catch (error) { + try { + dependencies.resetGuard(); + } catch { + // Preserve the activation failure after best-effort rollback. + } + throw error; + } + active = true; + return (): void => { + if (!active) return; + active = false; + started = false; + if (timer !== undefined) { + dependencies.clearTimeout(timer); + timer = undefined; + } + resetSdk(); + dependencies.resetGuard(); + }; + }, + start: (_config: unknown): void => { + if (!active || started) return; + started = true; + dependencies.started(); + let attempts = 0; + const check = (): void => { + timer = undefined; + if (!active) return; + attempts += 1; + let sdk: LockrSdk | undefined; + try { + sdk = dependencies.getSdk(); + if (sdk && typeof sdk.host === 'string' && sdk.host.length > 0) { + const protocol = dependencies.location.protocol === 'https:' ? 'https' : 'http'; + const nextHost = `${protocol}://${dependencies.location.host}/integrations/lockr/api`; + const originalHost = sdk.host; + sdk.host = nextHost; + ownedSdk = sdk; + previousHost = originalHost; + installedHost = nextHost; + return; + } + } catch { + // Treat an unreadable or unwritable SDK as not ready. + } + if (attempts >= 50) { + dependencies.timedOut(); + return; + } + try { + timer = dependencies.setTimeout(check, 50); + } catch { + dependencies.timedOut(); + } + }; + check(); + }, + }); +} + +export function createLockrIntegrationRegistration(release: string): IntegrationRegistration { + return createLifecycleIntegrationRegistration(LOCKR_INTEGRATION_ID, release, { + validateConfig: (candidate) => candidate === undefined, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts b/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts new file mode 100644 index 000000000..7592bfadc --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts @@ -0,0 +1,539 @@ +import { log } from '../../core/log'; + +const MARKER_COOKIE_NAME = '_ts_consent_src'; +const MARKER_COOKIE_VALUE = 'osano'; +const US_PRIVACY_COOKIE_NAME = 'us_privacy'; +const GPP_COOKIE_NAME = '__gpp'; +const GPP_SID_COOKIE_NAME = '__gpp_sid'; +const TCF_COOKIE_NAME = 'euconsent-v2'; +const TARGET_COOKIE_NAMES = [ + US_PRIVACY_COOKIE_NAME, + GPP_COOKIE_NAME, + GPP_SID_COOKIE_NAME, + TCF_COOKIE_NAME, +]; +const API_TIMEOUT_MS = 500; +const OSANO_RETRY_DELAY_MS = 250; +const OSANO_MAX_RETRIES = 20; +const MIRROR_DEBOUNCE_MS = 0; + +const OSANO_EVENTS = [ + 'osano-cm-initialized', + 'osano-cm-consent-saved', + 'osano-cm-consent-new', + 'osano-cm-consent-changed', + 'osano-cm-opt-out', + 'osano-cm-storage', +] as const; +const OSANO_CLEAR_READY_EVENTS = new Set([ + 'osano-cm-initialized', + 'osano-cm-consent-saved', + 'osano-cm-consent-new', + 'osano-cm-consent-changed', + 'osano-cm-opt-out', +]); + +interface UspData { + uspString?: string; +} + +interface GppPingData { + signalStatus?: string; + gppString?: string; + applicableSections?: number[]; +} + +interface TcfData { + tcString?: string; + eventStatus?: string; +} + +interface OsanoCm { + addEventListener?: (eventName: string, callback: (payload?: unknown) => void) => void; + removeEventListener?: (eventName: string, callback: (payload?: unknown) => void) => void; +} + +type UspApi = ( + command: 'getUSPData', + version: 1, + callback: (data?: UspData, success?: boolean) => void +) => void; + +type GppApi = (command: 'ping', callback: (data?: GppPingData, success?: boolean) => void) => void; + +type TcfApi = ( + command: 'getTCData', + version: 2, + callback: (data?: TcfData, success?: boolean) => void +) => void; + +type OsanoWindow = Window & { + Osano?: { + cm?: OsanoCm; + }; + __uspapi?: UspApi; + __gpp?: GppApi; + __tcfapi?: TcfApi; +}; + +interface CookieWrite { + name: string; + value: string; +} + +interface SignalResult { + writes: CookieWrite[]; + clears: string[]; + pending: boolean; +} + +interface MirrorPlan { + writes: CookieWrite[]; + clears: string[]; + pending: boolean; +} + +let initialized = false; +let osanoListenersInstalled = false; +let osanoRetryCount = 0; +let osanoReadyForClears = false; +let osanoRetryTimer: number | undefined; +let mirrorTimer: number | undefined; +let mirrorGeneration = 0; +let osanoEventHandlers: Map void> | undefined; +let osanoListenerOwner: OsanoCm | undefined; +let focusHandler: (() => void) | undefined; +let visibilityHandler: (() => void) | undefined; +const pendingSignalCancels = new Set<() => void>(); + +function getWindow(): OsanoWindow | undefined { + if (typeof window === 'undefined') return undefined; + return window as OsanoWindow; +} + +function readCookie(name: string): string | undefined { + if (typeof document === 'undefined') return undefined; + + const prefix = `${name}=`; + const cookie = document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); + return cookie?.slice(prefix.length); +} + +function writeCookie(name: string, value: string): void { + document.cookie = `${name}=${value}; Path=/; Secure; SameSite=Lax`; +} + +function clearCookie(name: string): void { + document.cookie = `${name}=; Path=/; Secure; SameSite=Lax; Max-Age=0`; +} + +function hasAnyTargetCookie(): boolean { + return TARGET_COOKIE_NAMES.some((name) => readCookie(name) !== undefined); +} + +function ownsConsentCookies(): boolean { + return readCookie(MARKER_COOKIE_NAME) === MARKER_COOKIE_VALUE; +} + +function canWriteConsentCookies(): boolean { + const marker = readCookie(MARKER_COOKIE_NAME); + if (marker === MARKER_COOKIE_VALUE) return true; + + if (marker !== undefined) { + log.debug('osano: preserving consent cookies owned by another mirror', { marker }); + return false; + } + + if (hasAnyTargetCookie()) { + log.debug('osano: preserving existing unmarked consent cookies'); + return false; + } + + return true; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isNumberArray(value: unknown): value is number[] { + return Array.isArray(value) && value.every((item) => typeof item === 'number'); +} + +function shouldWriteGppSid( + applicableSections: number[] | undefined +): applicableSections is number[] { + return ( + Array.isArray(applicableSections) && + applicableSections.length > 0 && + !applicableSections.includes(-1) + ); +} + +function isTcfReady(eventStatus: unknown): boolean { + return eventStatus === 'tcloaded' || eventStatus === 'useractioncomplete'; +} + +function signalResult(writes: CookieWrite[] = [], clears: string[] = []): SignalResult { + return { writes, clears, pending: false }; +} + +function pendingResult(): SignalResult { + return { writes: [], clears: [], pending: true }; +} + +function unavailableResult(): SignalResult { + return { writes: [], clears: [], pending: false }; +} + +function emptyAfterOsanoReadyResult(cookieNames: string | string[]): SignalResult { + if (!osanoReadyForClears) { + return pendingResult(); + } + + return signalResult([], Array.isArray(cookieNames) ? cookieNames : [cookieNames]); +} + +function finishOnce(finish: (value: T) => void): (value: T) => void { + let settled = false; + return (value: T): void => { + if (settled) return; + settled = true; + finish(value); + }; +} + +function readUspSignal(win: OsanoWindow): Promise { + if (typeof win.__uspapi !== 'function') return Promise.resolve(unavailableResult()); + + return new Promise((resolve) => { + let timer: number | undefined; + let cancelPending = (): void => undefined; + const done = finishOnce((result: SignalResult) => { + if (timer !== undefined) window.clearTimeout(timer); + pendingSignalCancels.delete(cancelPending); + resolve(result); + }); + cancelPending = () => done(pendingResult()); + pendingSignalCancels.add(cancelPending); + timer = window.setTimeout(cancelPending, API_TIMEOUT_MS); + + try { + win.__uspapi?.('getUSPData', 1, (data, success) => { + if (success === false || !isRecord(data)) { + done(pendingResult()); + return; + } + + if ('uspString' in data && typeof data.uspString !== 'string') { + done(pendingResult()); + return; + } + + if (typeof data.uspString === 'string' && data.uspString.length > 0) { + done(signalResult([{ name: US_PRIVACY_COOKIE_NAME, value: data.uspString }])); + return; + } + + done(emptyAfterOsanoReadyResult(US_PRIVACY_COOKIE_NAME)); + }); + } catch (error) { + log.debug('osano: __uspapi getUSPData failed', { error }); + done(pendingResult()); + } + }); +} + +function readGppSignal(win: OsanoWindow): Promise { + if (typeof win.__gpp !== 'function') return Promise.resolve(unavailableResult()); + + return new Promise((resolve) => { + let timer: number | undefined; + let cancelPending = (): void => undefined; + const done = finishOnce((result: SignalResult) => { + if (timer !== undefined) window.clearTimeout(timer); + pendingSignalCancels.delete(cancelPending); + resolve(result); + }); + cancelPending = () => done(pendingResult()); + pendingSignalCancels.add(cancelPending); + timer = window.setTimeout(cancelPending, API_TIMEOUT_MS); + + try { + win.__gpp?.('ping', (data, success) => { + if (success === false || !isRecord(data)) { + done(pendingResult()); + return; + } + + if (data.signalStatus !== 'ready') { + done(pendingResult()); + return; + } + + if ('gppString' in data && typeof data.gppString !== 'string') { + done(pendingResult()); + return; + } + + if ( + 'applicableSections' in data && + data.applicableSections !== undefined && + !isNumberArray(data.applicableSections) + ) { + done(pendingResult()); + return; + } + + const applicableSections = data.applicableSections as number[] | undefined; + if (typeof data.gppString === 'string' && data.gppString.length > 0) { + const writes = [{ name: GPP_COOKIE_NAME, value: data.gppString }]; + const clears: string[] = []; + + if (shouldWriteGppSid(applicableSections)) { + writes.push({ name: GPP_SID_COOKIE_NAME, value: applicableSections.join(',') }); + } else { + clears.push(GPP_SID_COOKIE_NAME); + } + + done(signalResult(writes, clears)); + return; + } + + done(emptyAfterOsanoReadyResult([GPP_COOKIE_NAME, GPP_SID_COOKIE_NAME])); + }); + } catch (error) { + log.debug('osano: __gpp ping failed', { error }); + done(pendingResult()); + } + }); +} + +function readTcfSignal(win: OsanoWindow): Promise { + if (typeof win.__tcfapi !== 'function') return Promise.resolve(unavailableResult()); + + return new Promise((resolve) => { + let timer: number | undefined; + let cancelPending = (): void => undefined; + const done = finishOnce((result: SignalResult) => { + if (timer !== undefined) window.clearTimeout(timer); + pendingSignalCancels.delete(cancelPending); + resolve(result); + }); + cancelPending = () => done(pendingResult()); + pendingSignalCancels.add(cancelPending); + timer = window.setTimeout(cancelPending, API_TIMEOUT_MS); + + try { + win.__tcfapi?.('getTCData', 2, (data, success) => { + if (success === false || !isRecord(data)) { + done(pendingResult()); + return; + } + + if (!isTcfReady(data.eventStatus)) { + done(pendingResult()); + return; + } + + if ('tcString' in data && typeof data.tcString !== 'string') { + done(pendingResult()); + return; + } + + if (typeof data.tcString === 'string' && data.tcString.length > 0) { + done(signalResult([{ name: TCF_COOKIE_NAME, value: data.tcString }])); + return; + } + + done(emptyAfterOsanoReadyResult(TCF_COOKIE_NAME)); + }); + } catch (error) { + log.debug('osano: __tcfapi getTCData failed', { error }); + done(pendingResult()); + } + }); +} + +async function buildMirrorPlan(win: OsanoWindow): Promise { + const results = await Promise.all([readUspSignal(win), readGppSignal(win), readTcfSignal(win)]); + + return { + writes: results.flatMap((result) => result.writes), + clears: results.flatMap((result) => result.clears), + pending: results.some((result) => result.pending), + }; +} + +function applyMirrorPlan(plan: MirrorPlan): boolean { + if (plan.writes.length === 0 && plan.clears.length === 0) { + return false; + } + + if (!canWriteConsentCookies()) { + return false; + } + + const writeNames = new Set(plan.writes.map((write) => write.name)); + for (const name of plan.clears) { + if (!writeNames.has(name)) clearCookie(name); + } + + for (const write of plan.writes) { + writeCookie(write.name, write.value); + } + + if (hasAnyTargetCookie()) { + writeCookie(MARKER_COOKIE_NAME, MARKER_COOKIE_VALUE); + } else if (ownsConsentCookies()) { + clearCookie(MARKER_COOKIE_NAME); + } + + log.info('osano: mirrored consent to standard cookies', { + writes: plan.writes.map((write) => write.name), + clears: plan.clears, + pending: plan.pending, + }); + + return true; +} + +/** + * Mirrors Osano's IAB API consent signals into standard first-party cookies. + * + * Returns `true` when any cookie was written or cleared, `false` otherwise. + */ +export async function mirrorOsanoConsent(): Promise { + if (typeof document === 'undefined') return false; + + const win = getWindow(); + if (!win) return false; + + const generation = (mirrorGeneration += 1); + const plan = await buildMirrorPlan(win); + + if (generation !== mirrorGeneration) { + return false; + } + + return applyMirrorPlan(plan); +} + +function scheduleMirror(): void { + if (mirrorTimer !== undefined || typeof window === 'undefined') return; + + mirrorTimer = window.setTimeout(() => { + mirrorTimer = undefined; + void mirrorOsanoConsent(); + }, MIRROR_DEBOUNCE_MS); +} + +function installOsanoListeners(): boolean { + const cm = getWindow()?.Osano?.cm; + if (!cm) return false; + + if (osanoListenersInstalled) { + scheduleMirror(); + return true; + } + + if ( + typeof cm.addEventListener !== 'function' || + typeof cm.removeEventListener !== 'function' + ) { + return false; + } + + osanoEventHandlers = new Map(); + osanoListenerOwner = cm; + for (const eventName of OSANO_EVENTS) { + const handler = (): void => { + if (OSANO_CLEAR_READY_EVENTS.has(eventName)) { + osanoReadyForClears = true; + } + scheduleMirror(); + }; + osanoEventHandlers.set(eventName, handler); + cm.addEventListener(eventName, handler); + } + osanoListenersInstalled = true; + + scheduleMirror(); + return true; +} + +function scheduleOsanoRetry(): void { + if (osanoRetryTimer !== undefined || osanoRetryCount >= OSANO_MAX_RETRIES) return; + + osanoRetryCount += 1; + osanoRetryTimer = window.setTimeout(() => { + osanoRetryTimer = undefined; + if (!installOsanoListeners()) { + scheduleOsanoRetry(); + } + }, OSANO_RETRY_DELAY_MS); +} + +function mirrorOnVisible(): void { + if (document.visibilityState === 'visible') { + scheduleMirror(); + } +} + +/** + * Initializes the Osano consent mirror. + */ +export function initializeOsanoConsentMirror(): void { + if (initialized || typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + initialized = true; + focusHandler = () => scheduleMirror(); + visibilityHandler = () => mirrorOnVisible(); + window.addEventListener('focus', focusHandler); + document.addEventListener('visibilitychange', visibilityHandler); + + scheduleMirror(); + + if (!installOsanoListeners()) { + scheduleOsanoRetry(); + } +} + +/** Dispose every timer/listener owned by the active Osano consent mirror. */ +export function disposeOsanoConsentMirror(): void { + const cm = osanoListenerOwner; + if ( + osanoListenersInstalled && + osanoEventHandlers && + cm && + typeof cm.removeEventListener === 'function' + ) { + for (const [eventName, handler] of osanoEventHandlers) { + try { + cm.removeEventListener(eventName, handler); + } catch { + // One vendor listener failure cannot retain the remaining owners. + } + } + } + + if (focusHandler) window.removeEventListener('focus', focusHandler); + if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler); + if (osanoRetryTimer !== undefined) window.clearTimeout(osanoRetryTimer); + if (mirrorTimer !== undefined) window.clearTimeout(mirrorTimer); + mirrorGeneration += 1; + for (const cancel of [...pendingSignalCancels]) cancel(); + + initialized = false; + osanoListenersInstalled = false; + osanoRetryCount = 0; + osanoReadyForClears = false; + osanoRetryTimer = undefined; + mirrorTimer = undefined; + osanoEventHandlers = undefined; + osanoListenerOwner = undefined; + focusHandler = undefined; + visibilityHandler = undefined; +} diff --git a/crates/trusted-server-js/lib/src/integrations/osano/index.ts b/crates/trusted-server-js/lib/src/integrations/osano/index.ts index ab12df202..135b44591 100644 --- a/crates/trusted-server-js/lib/src/integrations/osano/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/osano/index.ts @@ -1,514 +1,10 @@ -import { log } from '../../core/log'; +export { + disposeOsanoConsentMirror, + initializeOsanoConsentMirror, + mirrorOsanoConsent, +} from './consent_mirror'; -const MARKER_COOKIE_NAME = '_ts_consent_src'; -const MARKER_COOKIE_VALUE = 'osano'; -const US_PRIVACY_COOKIE_NAME = 'us_privacy'; -const GPP_COOKIE_NAME = '__gpp'; -const GPP_SID_COOKIE_NAME = '__gpp_sid'; -const TCF_COOKIE_NAME = 'euconsent-v2'; -const TARGET_COOKIE_NAMES = [ - US_PRIVACY_COOKIE_NAME, - GPP_COOKIE_NAME, - GPP_SID_COOKIE_NAME, - TCF_COOKIE_NAME, -]; -const API_TIMEOUT_MS = 500; -const OSANO_RETRY_DELAY_MS = 250; -const OSANO_MAX_RETRIES = 20; -const MIRROR_DEBOUNCE_MS = 0; - -const OSANO_EVENTS = [ - 'osano-cm-initialized', - 'osano-cm-consent-saved', - 'osano-cm-consent-new', - 'osano-cm-consent-changed', - 'osano-cm-opt-out', - 'osano-cm-storage', -] as const; -const OSANO_CLEAR_READY_EVENTS = new Set([ - 'osano-cm-initialized', - 'osano-cm-consent-saved', - 'osano-cm-consent-new', - 'osano-cm-consent-changed', - 'osano-cm-opt-out', -]); - -interface UspData { - uspString?: string; -} - -interface GppPingData { - signalStatus?: string; - gppString?: string; - applicableSections?: number[]; -} - -interface TcfData { - tcString?: string; - eventStatus?: string; -} - -interface OsanoCm { - addEventListener?: (eventName: string, callback: (payload?: unknown) => void) => void; - removeEventListener?: (eventName: string, callback: (payload?: unknown) => void) => void; -} - -type UspApi = ( - command: 'getUSPData', - version: 1, - callback: (data?: UspData, success?: boolean) => void -) => void; - -type GppApi = (command: 'ping', callback: (data?: GppPingData, success?: boolean) => void) => void; - -type TcfApi = ( - command: 'getTCData', - version: 2, - callback: (data?: TcfData, success?: boolean) => void -) => void; - -type OsanoWindow = Window & { - Osano?: { - cm?: OsanoCm; - }; - __uspapi?: UspApi; - __gpp?: GppApi; - __tcfapi?: TcfApi; -}; - -interface CookieWrite { - name: string; - value: string; -} - -interface SignalResult { - writes: CookieWrite[]; - clears: string[]; - pending: boolean; -} - -interface MirrorPlan { - writes: CookieWrite[]; - clears: string[]; - pending: boolean; -} - -let initialized = false; -let osanoListenersInstalled = false; -let osanoRetryCount = 0; -let osanoReadyForClears = false; -let osanoRetryTimer: number | undefined; -let mirrorTimer: number | undefined; -let mirrorGeneration = 0; -let osanoEventHandlers: Map void> | undefined; -let focusHandler: (() => void) | undefined; -let visibilityHandler: (() => void) | undefined; - -function getWindow(): OsanoWindow | undefined { - if (typeof window === 'undefined') return undefined; - return window as OsanoWindow; -} - -function readCookie(name: string): string | undefined { - if (typeof document === 'undefined') return undefined; - - const prefix = `${name}=`; - const cookie = document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); - return cookie?.slice(prefix.length); -} - -function writeCookie(name: string, value: string): void { - document.cookie = `${name}=${value}; Path=/; Secure; SameSite=Lax`; -} - -function clearCookie(name: string): void { - document.cookie = `${name}=; Path=/; Secure; SameSite=Lax; Max-Age=0`; -} - -function hasAnyTargetCookie(): boolean { - return TARGET_COOKIE_NAMES.some((name) => readCookie(name) !== undefined); -} - -function ownsConsentCookies(): boolean { - return readCookie(MARKER_COOKIE_NAME) === MARKER_COOKIE_VALUE; -} - -function canWriteConsentCookies(): boolean { - const marker = readCookie(MARKER_COOKIE_NAME); - if (marker === MARKER_COOKIE_VALUE) return true; - - if (marker !== undefined) { - log.debug('osano: preserving consent cookies owned by another mirror', { marker }); - return false; - } - - if (hasAnyTargetCookie()) { - log.debug('osano: preserving existing unmarked consent cookies'); - return false; - } - - return true; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function isNumberArray(value: unknown): value is number[] { - return Array.isArray(value) && value.every((item) => typeof item === 'number'); -} - -function shouldWriteGppSid( - applicableSections: number[] | undefined -): applicableSections is number[] { - return ( - Array.isArray(applicableSections) && - applicableSections.length > 0 && - !applicableSections.includes(-1) - ); -} - -function isTcfReady(eventStatus: unknown): boolean { - return eventStatus === 'tcloaded' || eventStatus === 'useractioncomplete'; -} - -function signalResult(writes: CookieWrite[] = [], clears: string[] = []): SignalResult { - return { writes, clears, pending: false }; -} - -function pendingResult(): SignalResult { - return { writes: [], clears: [], pending: true }; -} - -function unavailableResult(): SignalResult { - return { writes: [], clears: [], pending: false }; -} - -function emptyAfterOsanoReadyResult(cookieNames: string | string[]): SignalResult { - if (!osanoReadyForClears) { - return pendingResult(); - } - - return signalResult([], Array.isArray(cookieNames) ? cookieNames : [cookieNames]); -} - -function finishOnce(finish: (value: T) => void): (value: T) => void { - let settled = false; - return (value: T): void => { - if (settled) return; - settled = true; - finish(value); - }; -} - -function readUspSignal(win: OsanoWindow): Promise { - if (typeof win.__uspapi !== 'function') return Promise.resolve(unavailableResult()); - - return new Promise((resolve) => { - const done = finishOnce((result: SignalResult) => { - window.clearTimeout(timer); - resolve(result); - }); - const timer = window.setTimeout(() => done(pendingResult()), API_TIMEOUT_MS); - - try { - win.__uspapi?.('getUSPData', 1, (data, success) => { - if (success === false || !isRecord(data)) { - done(pendingResult()); - return; - } - - if ('uspString' in data && typeof data.uspString !== 'string') { - done(pendingResult()); - return; - } - - if (typeof data.uspString === 'string' && data.uspString.length > 0) { - done(signalResult([{ name: US_PRIVACY_COOKIE_NAME, value: data.uspString }])); - return; - } - - done(emptyAfterOsanoReadyResult(US_PRIVACY_COOKIE_NAME)); - }); - } catch (error) { - log.debug('osano: __uspapi getUSPData failed', { error }); - done(pendingResult()); - } - }); -} - -function readGppSignal(win: OsanoWindow): Promise { - if (typeof win.__gpp !== 'function') return Promise.resolve(unavailableResult()); - - return new Promise((resolve) => { - const done = finishOnce((result: SignalResult) => { - window.clearTimeout(timer); - resolve(result); - }); - const timer = window.setTimeout(() => done(pendingResult()), API_TIMEOUT_MS); - - try { - win.__gpp?.('ping', (data, success) => { - if (success === false || !isRecord(data)) { - done(pendingResult()); - return; - } - - if (data.signalStatus !== 'ready') { - done(pendingResult()); - return; - } - - if ('gppString' in data && typeof data.gppString !== 'string') { - done(pendingResult()); - return; - } - - if ( - 'applicableSections' in data && - data.applicableSections !== undefined && - !isNumberArray(data.applicableSections) - ) { - done(pendingResult()); - return; - } - - const applicableSections = data.applicableSections as number[] | undefined; - if (typeof data.gppString === 'string' && data.gppString.length > 0) { - const writes = [{ name: GPP_COOKIE_NAME, value: data.gppString }]; - const clears: string[] = []; - - if (shouldWriteGppSid(applicableSections)) { - writes.push({ name: GPP_SID_COOKIE_NAME, value: applicableSections.join(',') }); - } else { - clears.push(GPP_SID_COOKIE_NAME); - } - - done(signalResult(writes, clears)); - return; - } - - done(emptyAfterOsanoReadyResult([GPP_COOKIE_NAME, GPP_SID_COOKIE_NAME])); - }); - } catch (error) { - log.debug('osano: __gpp ping failed', { error }); - done(pendingResult()); - } - }); -} - -function readTcfSignal(win: OsanoWindow): Promise { - if (typeof win.__tcfapi !== 'function') return Promise.resolve(unavailableResult()); - - return new Promise((resolve) => { - const done = finishOnce((result: SignalResult) => { - window.clearTimeout(timer); - resolve(result); - }); - const timer = window.setTimeout(() => done(pendingResult()), API_TIMEOUT_MS); - - try { - win.__tcfapi?.('getTCData', 2, (data, success) => { - if (success === false || !isRecord(data)) { - done(pendingResult()); - return; - } - - if (!isTcfReady(data.eventStatus)) { - done(pendingResult()); - return; - } - - if ('tcString' in data && typeof data.tcString !== 'string') { - done(pendingResult()); - return; - } - - if (typeof data.tcString === 'string' && data.tcString.length > 0) { - done(signalResult([{ name: TCF_COOKIE_NAME, value: data.tcString }])); - return; - } - - done(emptyAfterOsanoReadyResult(TCF_COOKIE_NAME)); - }); - } catch (error) { - log.debug('osano: __tcfapi getTCData failed', { error }); - done(pendingResult()); - } - }); -} - -async function buildMirrorPlan(win: OsanoWindow): Promise { - const results = await Promise.all([readUspSignal(win), readGppSignal(win), readTcfSignal(win)]); - - return { - writes: results.flatMap((result) => result.writes), - clears: results.flatMap((result) => result.clears), - pending: results.some((result) => result.pending), - }; -} - -function applyMirrorPlan(plan: MirrorPlan): boolean { - if (plan.writes.length === 0 && plan.clears.length === 0) { - return false; - } - - if (!canWriteConsentCookies()) { - return false; - } - - const writeNames = new Set(plan.writes.map((write) => write.name)); - for (const name of plan.clears) { - if (!writeNames.has(name)) clearCookie(name); - } - - for (const write of plan.writes) { - writeCookie(write.name, write.value); - } - - if (hasAnyTargetCookie()) { - writeCookie(MARKER_COOKIE_NAME, MARKER_COOKIE_VALUE); - } else if (ownsConsentCookies()) { - clearCookie(MARKER_COOKIE_NAME); - } - - log.info('osano: mirrored consent to standard cookies', { - writes: plan.writes.map((write) => write.name), - clears: plan.clears, - pending: plan.pending, - }); - - return true; -} - -/** - * Mirrors Osano's IAB API consent signals into standard first-party cookies. - * - * Returns `true` when any cookie was written or cleared, `false` otherwise. - */ -export async function mirrorOsanoConsent(): Promise { - if (typeof document === 'undefined') return false; - - const win = getWindow(); - if (!win) return false; - - const generation = (mirrorGeneration += 1); - const plan = await buildMirrorPlan(win); - - if (generation !== mirrorGeneration) { - return false; - } - - return applyMirrorPlan(plan); -} - -function scheduleMirror(): void { - if (mirrorTimer !== undefined || typeof window === 'undefined') return; - - mirrorTimer = window.setTimeout(() => { - mirrorTimer = undefined; - void mirrorOsanoConsent(); - }, MIRROR_DEBOUNCE_MS); -} - -function installOsanoListeners(): boolean { - const cm = getWindow()?.Osano?.cm; - if (!cm) return false; - - if (osanoListenersInstalled) { - scheduleMirror(); - return true; - } - - if (typeof cm.addEventListener !== 'function') { - return false; - } - - osanoEventHandlers = new Map(); - for (const eventName of OSANO_EVENTS) { - const handler = (): void => { - if (OSANO_CLEAR_READY_EVENTS.has(eventName)) { - osanoReadyForClears = true; - } - scheduleMirror(); - }; - osanoEventHandlers.set(eventName, handler); - cm.addEventListener(eventName, handler); - } - osanoListenersInstalled = true; - - scheduleMirror(); - return true; -} - -function scheduleOsanoRetry(): void { - if (osanoRetryTimer !== undefined || osanoRetryCount >= OSANO_MAX_RETRIES) return; - - osanoRetryCount += 1; - osanoRetryTimer = window.setTimeout(() => { - osanoRetryTimer = undefined; - if (!installOsanoListeners()) { - scheduleOsanoRetry(); - } - }, OSANO_RETRY_DELAY_MS); -} - -function mirrorOnVisible(): void { - if (document.visibilityState === 'visible') { - scheduleMirror(); - } -} - -/** - * Initializes the Osano consent mirror. - */ -export function initializeOsanoConsentMirror(): void { - if (initialized || typeof window === 'undefined' || typeof document === 'undefined') { - return; - } - - initialized = true; - focusHandler = () => scheduleMirror(); - visibilityHandler = () => mirrorOnVisible(); - window.addEventListener('focus', focusHandler); - document.addEventListener('visibilitychange', visibilityHandler); - - scheduleMirror(); - - if (!installOsanoListeners()) { - scheduleOsanoRetry(); - } -} - -/** Resets module state for unit tests. */ -export function resetOsanoConsentMirrorForTest(): void { - const cm = getWindow()?.Osano?.cm; - if ( - osanoListenersInstalled && - osanoEventHandlers && - cm && - typeof cm.removeEventListener === 'function' - ) { - for (const [eventName, handler] of osanoEventHandlers) { - cm.removeEventListener(eventName, handler); - } - } - - if (focusHandler) window.removeEventListener('focus', focusHandler); - if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler); - if (osanoRetryTimer !== undefined) window.clearTimeout(osanoRetryTimer); - if (mirrorTimer !== undefined) window.clearTimeout(mirrorTimer); - - initialized = false; - osanoListenersInstalled = false; - osanoRetryCount = 0; - osanoReadyForClears = false; - mirrorGeneration = 0; - osanoRetryTimer = undefined; - mirrorTimer = undefined; - osanoEventHandlers = undefined; - focusHandler = undefined; - visibilityHandler = undefined; -} +import { initializeOsanoConsentMirror } from './consent_mirror'; +// Legacy entry point retained until the coordinated Task 19 wiring cutover. initializeOsanoConsentMirror(); diff --git a/crates/trusted-server-js/lib/src/integrations/osano/module.ts b/crates/trusted-server-js/lib/src/integrations/osano/module.ts new file mode 100644 index 000000000..2e81f6b49 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/osano/module.ts @@ -0,0 +1,49 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; + +import { disposeOsanoConsentMirror, initializeOsanoConsentMirror } from './consent_mirror'; + +export const OSANO_INTEGRATION_ID = 'osano' as const; + +export interface OsanoRuntimeDependencies { + readonly initialize: () => void; + readonly reset: () => void; +} + +/** Bind the existing consent mirror's complete lifecycle to one release. */ +export function createOsanoRuntime( + dependencies: OsanoRuntimeDependencies = { + initialize: initializeOsanoConsentMirror, + reset: disposeOsanoConsentMirror, + } +): IntegrationLifecycleRuntime { + let active = false; + let started = false; + return Object.freeze({ + activate: (_config: unknown) => { + if (active) throw new Error('Osano runtime is already active'); + active = true; + started = false; + return (): void => { + if (!active) return; + active = false; + started = false; + dependencies.reset(); + }; + }, + start: (_config: unknown) => { + if (!active || started) return; + started = true; + dependencies.initialize(); + }, + }); +} + +export function createOsanoIntegrationRegistration(release: string): IntegrationRegistration { + return createLifecycleIntegrationRegistration(OSANO_INTEGRATION_ID, release, { + validateConfig: (candidate) => candidate === undefined, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/permutive/module.ts b/crates/trusted-server-js/lib/src/integrations/permutive/module.ts new file mode 100644 index 000000000..9ccadbfa1 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/permutive/module.ts @@ -0,0 +1,210 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; +import { log } from '../../core/log'; + +import { installPermutiveGuard, resetGuardState } from './script_guard'; +import { getPermutiveSegments } from './segments'; + +export const PERMUTIVE_INTEGRATION_ID = 'permutive' as const; + +const PERMUTIVE_CONFIG_FIELDS = [ + 'apiHost', + 'apiProtocol', + 'cdnBaseUrl', + 'cdnProtocol', + 'secureSignalsApiHost', + 'segmentSyncApiHost', +] as const; + +type PermutiveConfigField = (typeof PERMUTIVE_CONFIG_FIELDS)[number]; +type PermutiveConfig = Record; + +interface PermutiveSdk { + readonly config: PermutiveConfig; +} + +type ContextContributor = () => Readonly> | undefined; + +export interface PermutiveRuntimeDependencies { + readonly clearTimeout: (timer: number) => void; + readonly getSdk: () => PermutiveSdk | undefined; + readonly getSegments: () => readonly string[]; + readonly installGuard: () => void; + readonly location: { readonly host: string; readonly protocol: string }; + readonly registerContext: (contributor: ContextContributor) => (() => void) | undefined; + readonly resetGuard: () => void; + readonly setTimeout: (callback: () => void, delay: number) => number; + readonly started: () => void; + readonly timedOut: () => void; +} + +function bestEffort(action: () => void): void { + try { + action(); + } catch { + // Cleanup is intentionally isolated so one failed release cannot retain another resource. + } +} + +function snapshotSegments(candidate: readonly string[]): readonly string[] { + const segments: string[] = []; + try { + const length = Math.min(candidate.length, 100); + for (let index = 0; index < length; index += 1) { + const segment = candidate[index]; + if (typeof segment === 'string') segments.push(segment); + } + } catch { + return Object.freeze([]); + } + return Object.freeze(segments); +} + +/** Own the Permutive guard, auction context, SDK readiness timer, and rewritten config. */ +export function createPermutiveRuntime( + overrides: Partial = {} +): IntegrationLifecycleRuntime { + const dependencies: PermutiveRuntimeDependencies = { + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => (globalThis as typeof globalThis & { permutive?: PermutiveSdk }).permutive, + getSegments: getPermutiveSegments, + installGuard: installPermutiveGuard, + location: window.location, + registerContext: () => undefined, + resetGuard: resetGuardState, + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: () => log.info('Permutive integration initialized'), + timedOut: () => log.warn('Permutive SDK not detected after', 2_500, 'ms'), + ...overrides, + }; + let active = false; + let started = false; + let timer: number | undefined; + let releaseContext: (() => void) | undefined; + let ownedConfig: PermutiveConfig | undefined; + let installedValues: Readonly | undefined; + let previousValues: Readonly | undefined; + + const resetSdk = (): void => { + const config = ownedConfig; + const installed = installedValues; + const previous = previousValues; + ownedConfig = undefined; + installedValues = undefined; + previousValues = undefined; + if (!config || !installed || !previous) return; + + for (const field of PERMUTIVE_CONFIG_FIELDS) { + bestEffort(() => { + if (config[field] === installed[field]) config[field] = previous[field]; + }); + } + }; + + const installSdkConfig = (config: PermutiveConfig): boolean => { + const protocol = dependencies.location.protocol === 'https:' ? 'https' : 'http'; + const host = dependencies.location.host; + const next: PermutiveConfig = { + apiHost: `${host}/integrations/permutive/api`, + apiProtocol: protocol, + cdnBaseUrl: `${host}/integrations/permutive/cdn`, + cdnProtocol: protocol, + secureSignalsApiHost: `${host}/integrations/permutive/secure-signal`, + segmentSyncApiHost: `${host}/integrations/permutive/sync`, + }; + const previous = {} as PermutiveConfig; + const written: PermutiveConfigField[] = []; + try { + for (const field of PERMUTIVE_CONFIG_FIELDS) previous[field] = config[field]; + for (const field of PERMUTIVE_CONFIG_FIELDS) { + config[field] = next[field]; + written.push(field); + } + } catch { + for (const field of written.reverse()) { + bestEffort(() => { + if (config[field] === next[field]) config[field] = previous[field]; + }); + } + return false; + } + ownedConfig = config; + previousValues = Object.freeze({ ...previous }); + installedValues = Object.freeze({ ...next }); + return true; + }; + + return Object.freeze({ + activate: (_config: unknown): (() => void) => { + if (active) throw new Error('Permutive runtime is already active'); + try { + dependencies.installGuard(); + releaseContext = dependencies.registerContext(() => { + try { + const segments = snapshotSegments(dependencies.getSegments()); + if (segments.length === 0) return undefined; + return Object.freeze({ permutive_segments: segments }); + } catch { + return undefined; + } + }); + if (!releaseContext) throw new Error('Permutive context registration failed'); + } catch (error) { + releaseContext = undefined; + bestEffort(dependencies.resetGuard); + throw error; + } + active = true; + return (): void => { + if (!active) return; + active = false; + started = false; + if (timer !== undefined) { + bestEffort(() => dependencies.clearTimeout(timer as number)); + timer = undefined; + } + resetSdk(); + const release = releaseContext; + releaseContext = undefined; + if (release) bestEffort(release); + bestEffort(dependencies.resetGuard); + }; + }, + start: (_config: unknown): void => { + if (!active || started) return; + started = true; + dependencies.started(); + let attempts = 0; + const check = (): void => { + timer = undefined; + if (!active) return; + attempts += 1; + try { + const sdk = dependencies.getSdk(); + if (sdk?.config && installSdkConfig(sdk.config)) return; + } catch { + // Treat an unreadable SDK as not ready. + } + if (attempts >= 50) { + dependencies.timedOut(); + return; + } + try { + timer = dependencies.setTimeout(check, 50); + } catch { + dependencies.timedOut(); + } + }; + check(); + }, + }); +} + +export function createPermutiveIntegrationRegistration(release: string): IntegrationRegistration { + return createLifecycleIntegrationRegistration(PERMUTIVE_INTEGRATION_ID, release, { + validateConfig: (candidate) => candidate === undefined, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_mirror.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_mirror.ts new file mode 100644 index 000000000..7dc6e6b3a --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_mirror.ts @@ -0,0 +1,299 @@ +import { log } from '../../core/log'; + +const SP_CONSENT_PREFIX = '_sp_user_consent_'; +const GPP_COOKIE_NAME = '__gpp'; +const GPP_SID_COOKIE_NAME = '__gpp_sid'; +const GPP_SOURCE_COOKIE_NAME = '_ts_gpp_src'; +const GPP_SOURCE_SOURCEPOINT = 'sp'; +const INITIAL_RETRY_DELAY_MS = 500; + +interface SourcepointGppData { + gppString?: string | undefined; + applicableSections?: number[] | undefined; +} + +interface SourcepointConsentStringEntry { + sectionId?: number | undefined; +} + +interface SourcepointSectionPayload { + consentString?: string | undefined; + applicableSections?: number[] | undefined; + consentStrings?: SourcepointConsentStringEntry[] | undefined; +} + +interface SourcepointConsentPayload { + gppData?: SourcepointGppData | undefined; + [key: string]: unknown; +} + +interface MirroredSourcepointConsent { + gppString: string; + applicableSections?: number[] | undefined; +} + +let initialized = false; +let initialRetryDone = false; +let retryTimer: number | undefined; +let domContentLoadedHandler: (() => void) | undefined; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isNumberArray(value: unknown): value is number[] { + return Array.isArray(value) && value.every((item) => typeof item === 'number'); +} + +function isConsentStringEntryArray(value: unknown): value is SourcepointConsentStringEntry[] { + return ( + Array.isArray(value) && + value.every( + (item) => + isRecord(item) && + (typeof item.sectionId === 'number' || typeof item.sectionId === 'undefined') + ) + ); +} + +function normalizeSectionPayload(value: unknown): SourcepointSectionPayload | null { + if (!isRecord(value)) return null; + + return { + consentString: typeof value.consentString === 'string' ? value.consentString : undefined, + applicableSections: isNumberArray(value.applicableSections) + ? value.applicableSections + : undefined, + consentStrings: isConsentStringEntryArray(value.consentStrings) + ? value.consentStrings + : undefined, + }; +} + +function sectionIdsFromConsentStrings( + consentStrings: SourcepointConsentStringEntry[] | undefined +): number[] | undefined { + const ids = consentStrings + ?.map((entry) => entry.sectionId) + .filter((sectionId): sectionId is number => typeof sectionId === 'number'); + + return ids && ids.length > 0 ? ids : undefined; +} + +function looksLikeGpp(consentString: string): boolean { + return consentString.includes('~'); +} + +function extractMirroredConsent( + payload: SourcepointConsentPayload +): MirroredSourcepointConsent | null { + if (payload.gppData?.gppString) { + return { + gppString: payload.gppData.gppString, + applicableSections: payload.gppData.applicableSections, + }; + } + + for (const [sectionName, rawSection] of Object.entries(payload)) { + if (sectionName === 'gppData') continue; + + const section = normalizeSectionPayload(rawSection); + if (!section?.consentString || !looksLikeGpp(section.consentString)) continue; + + return { + gppString: section.consentString, + applicableSections: + section.applicableSections ?? sectionIdsFromConsentStrings(section.consentStrings), + }; + } + + return null; +} + +function findSourcepointConsent(): MirroredSourcepointConsent | null { + // Sourcepoint stores one consent payload per property under `_sp_user_consent_*`. + // We intentionally take the first valid match and mirror that origin-scoped payload. + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(SP_CONSENT_PREFIX)) continue; + + const raw = localStorage.getItem(key); + if (!raw) continue; + + try { + const payload = JSON.parse(raw) as SourcepointConsentPayload; + const consent = extractMirroredConsent(payload); + if (consent) { + return consent; + } + } catch { + log.debug('sourcepoint: failed to parse localStorage value', { key }); + } + } + return null; +} + +function readCookie(name: string): string | undefined { + const prefix = `${name}=`; + const cookie = document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); + return cookie?.slice(prefix.length); +} + +function hasSourcepointMarker(): boolean { + return readCookie(GPP_SOURCE_COOKIE_NAME) === GPP_SOURCE_SOURCEPOINT; +} + +function writeCookie(name: string, value: string): void { + document.cookie = `${name}=${value}; path=/; Secure; SameSite=Lax`; +} + +function clearCookie(name: string): void { + document.cookie = `${name}=; path=/; Secure; SameSite=Lax; Max-Age=0`; +} + +function clearSourcepointCookies(): void { + if (!hasSourcepointMarker()) { + return; + } + + clearCookie(GPP_COOKIE_NAME); + clearCookie(GPP_SID_COOKIE_NAME); + clearCookie(GPP_SOURCE_COOKIE_NAME); +} + +function mirrorOnVisible(): void { + if (document.visibilityState === 'visible') { + mirrorSourcepointConsent(); + } +} + +function clearInitialRetryTimer(): void { + if (retryTimer === undefined) { + return; + } + + window.clearTimeout(retryTimer); + retryTimer = undefined; +} + +function clearDomContentLoadedHandler(): void { + if (!domContentLoadedHandler) return; + document.removeEventListener('DOMContentLoaded', domContentLoadedHandler); + domContentLoadedHandler = undefined; +} + +function scheduleInitialRetry(): void { + if (initialRetryDone || retryTimer !== undefined) { + return; + } + + const retry = (): void => { + if (initialRetryDone) { + return; + } + + initialRetryDone = true; + clearInitialRetryTimer(); + clearDomContentLoadedHandler(); + mirrorSourcepointConsent(); + }; + + if (document.readyState === 'loading') { + domContentLoadedHandler = retry; + document.addEventListener('DOMContentLoaded', retry, { once: true }); + } + + retryTimer = window.setTimeout(retry, INITIAL_RETRY_DELAY_MS); +} + +/** + * Reads Sourcepoint consent from localStorage and mirrors it into + * `__gpp` and `__gpp_sid` cookies for Trusted Server to read. + * + * Sourcepoint stores different shapes depending on the campaign/module. US + * National data is commonly stored under `usnat.consentString` and + * `usnat.applicableSections`, while some setups expose `gppData.gppString`. + * + * Returns `true` if cookies were written, `false` otherwise. + */ +export function mirrorSourcepointConsent(): boolean { + if (typeof localStorage === 'undefined' || typeof document === 'undefined') { + return false; + } + + const consent = findSourcepointConsent(); + if (!consent) { + clearSourcepointCookies(); + log.debug('sourcepoint: no GPP data found in localStorage'); + return false; + } + + const { gppString, applicableSections } = consent; + if (!gppString) { + clearSourcepointCookies(); + log.debug('sourcepoint: gppString is empty'); + return false; + } + + const existingGppCookie = readCookie(GPP_COOKIE_NAME); + if (existingGppCookie && existingGppCookie !== gppString && !hasSourcepointMarker()) { + log.debug('sourcepoint: preserving existing __gpp cookie from another writer'); + return false; + } + + writeCookie(GPP_SOURCE_COOKIE_NAME, GPP_SOURCE_SOURCEPOINT); + writeCookie(GPP_COOKIE_NAME, gppString); + + if (Array.isArray(applicableSections) && applicableSections.length > 0) { + writeCookie(GPP_SID_COOKIE_NAME, applicableSections.join(',')); + } else { + clearCookie(GPP_SID_COOKIE_NAME); + } + + initialRetryDone = true; + clearInitialRetryTimer(); + clearDomContentLoadedHandler(); + + log.info('sourcepoint: mirrored GPP consent to cookies', { + gppLength: gppString.length, + sections: applicableSections, + }); + + return true; +} + +/** + * Initializes Sourcepoint consent mirroring and bounded refresh hooks. + */ +export function initializeSourcepointConsentMirror(): void { + if (initialized || typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + initialized = true; + + if (!mirrorSourcepointConsent()) { + scheduleInitialRetry(); + } + + // Sourcepoint persists consent changes to localStorage. Re-mirror when a + // user returns to the page so session cookies do not remain stale. + document.addEventListener('visibilitychange', mirrorOnVisible); + window.addEventListener('focus', mirrorSourcepointConsent); +} + +/** Dispose every timer/listener owned by the active Sourcepoint consent mirror. */ +export function disposeSourcepointConsentMirror(): void { + if (typeof window !== 'undefined') { + window.removeEventListener('focus', mirrorSourcepointConsent); + clearInitialRetryTimer(); + } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', mirrorOnVisible); + clearDomContentLoadedHandler(); + } + initialized = false; + initialRetryDone = false; + retryTimer = undefined; + domContentLoadedHandler = undefined; +} diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts index 9e181c94d..442d14760 100644 --- a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts @@ -1,299 +1,23 @@ import { log } from '../../core/log'; +import { initializeSourcepointConsentMirror } from './consent_mirror'; import { installSourcepointGuard } from './script_guard'; +export { + disposeSourcepointConsentMirror, + initializeSourcepointConsentMirror, + mirrorSourcepointConsent, +} from './consent_mirror'; + type SourcepointWindow = Window & { - __tsjs_sourcepoint?: - | { - rewriteSdk?: boolean | undefined; - } - | undefined; + __tsjs_sourcepoint?: { rewriteSdk?: boolean }; }; -function shouldInstallSourcepointGuard(): boolean { - if (typeof window === 'undefined') { - return false; +// Legacy entry point retained until the coordinated Task 19 wiring cutover. +if (typeof window !== 'undefined') { + if ((window as SourcepointWindow).__tsjs_sourcepoint?.rewriteSdk !== false) { + installSourcepointGuard(); } - - const config = (window as SourcepointWindow).__tsjs_sourcepoint; - return config?.rewriteSdk !== false; -} - -if (typeof window !== 'undefined' && shouldInstallSourcepointGuard()) { - installSourcepointGuard(); + initializeSourcepointConsentMirror(); log.info('Sourcepoint integration initialized'); } - -const SP_CONSENT_PREFIX = '_sp_user_consent_'; -const GPP_COOKIE_NAME = '__gpp'; -const GPP_SID_COOKIE_NAME = '__gpp_sid'; -const GPP_SOURCE_COOKIE_NAME = '_ts_gpp_src'; -const GPP_SOURCE_SOURCEPOINT = 'sp'; -const INITIAL_RETRY_DELAY_MS = 500; - -interface SourcepointGppData { - gppString?: string | undefined; - applicableSections?: number[] | undefined; -} - -interface SourcepointConsentStringEntry { - sectionId?: number | undefined; -} - -interface SourcepointSectionPayload { - consentString?: string | undefined; - applicableSections?: number[] | undefined; - consentStrings?: SourcepointConsentStringEntry[] | undefined; -} - -interface SourcepointConsentPayload { - gppData?: SourcepointGppData | undefined; - [key: string]: unknown; -} - -interface MirroredSourcepointConsent { - gppString: string; - applicableSections?: number[] | undefined; -} - -let initialized = false; -let initialRetryDone = false; -let retryTimer: number | undefined; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function isNumberArray(value: unknown): value is number[] { - return Array.isArray(value) && value.every((item) => typeof item === 'number'); -} - -function isConsentStringEntryArray(value: unknown): value is SourcepointConsentStringEntry[] { - return ( - Array.isArray(value) && - value.every( - (item) => - isRecord(item) && - (typeof item.sectionId === 'number' || typeof item.sectionId === 'undefined') - ) - ); -} - -function normalizeSectionPayload(value: unknown): SourcepointSectionPayload | null { - if (!isRecord(value)) return null; - - return { - consentString: typeof value.consentString === 'string' ? value.consentString : undefined, - applicableSections: isNumberArray(value.applicableSections) - ? value.applicableSections - : undefined, - consentStrings: isConsentStringEntryArray(value.consentStrings) - ? value.consentStrings - : undefined, - }; -} - -function sectionIdsFromConsentStrings( - consentStrings: SourcepointConsentStringEntry[] | undefined -): number[] | undefined { - const ids = consentStrings - ?.map((entry) => entry.sectionId) - .filter((sectionId): sectionId is number => typeof sectionId === 'number'); - - return ids && ids.length > 0 ? ids : undefined; -} - -function looksLikeGpp(consentString: string): boolean { - return consentString.includes('~'); -} - -function extractMirroredConsent( - payload: SourcepointConsentPayload -): MirroredSourcepointConsent | null { - if (payload.gppData?.gppString) { - return { - gppString: payload.gppData.gppString, - applicableSections: payload.gppData.applicableSections, - }; - } - - for (const [sectionName, rawSection] of Object.entries(payload)) { - if (sectionName === 'gppData') continue; - - const section = normalizeSectionPayload(rawSection); - if (!section?.consentString || !looksLikeGpp(section.consentString)) continue; - - return { - gppString: section.consentString, - applicableSections: - section.applicableSections ?? sectionIdsFromConsentStrings(section.consentStrings), - }; - } - - return null; -} - -function findSourcepointConsent(): MirroredSourcepointConsent | null { - // Sourcepoint stores one consent payload per property under `_sp_user_consent_*`. - // We intentionally take the first valid match and mirror that origin-scoped payload. - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (!key?.startsWith(SP_CONSENT_PREFIX)) continue; - - const raw = localStorage.getItem(key); - if (!raw) continue; - - try { - const payload = JSON.parse(raw) as SourcepointConsentPayload; - const consent = extractMirroredConsent(payload); - if (consent) { - return consent; - } - } catch { - log.debug('sourcepoint: failed to parse localStorage value', { key }); - } - } - return null; -} - -function readCookie(name: string): string | undefined { - const prefix = `${name}=`; - const cookie = document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); - return cookie?.slice(prefix.length); -} - -function hasSourcepointMarker(): boolean { - return readCookie(GPP_SOURCE_COOKIE_NAME) === GPP_SOURCE_SOURCEPOINT; -} - -function writeCookie(name: string, value: string): void { - document.cookie = `${name}=${value}; path=/; Secure; SameSite=Lax`; -} - -function clearCookie(name: string): void { - document.cookie = `${name}=; path=/; Secure; SameSite=Lax; Max-Age=0`; -} - -function clearSourcepointCookies(): void { - if (!hasSourcepointMarker()) { - return; - } - - clearCookie(GPP_COOKIE_NAME); - clearCookie(GPP_SID_COOKIE_NAME); - clearCookie(GPP_SOURCE_COOKIE_NAME); -} - -function mirrorOnVisible(): void { - if (document.visibilityState === 'visible') { - mirrorSourcepointConsent(); - } -} - -function clearInitialRetryTimer(): void { - if (retryTimer === undefined) { - return; - } - - window.clearTimeout(retryTimer); - retryTimer = undefined; -} - -function scheduleInitialRetry(): void { - if (initialRetryDone || retryTimer !== undefined) { - return; - } - - const retry = (): void => { - if (initialRetryDone) { - return; - } - - initialRetryDone = true; - clearInitialRetryTimer(); - mirrorSourcepointConsent(); - }; - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', retry, { once: true }); - } - - retryTimer = window.setTimeout(retry, INITIAL_RETRY_DELAY_MS); -} - -/** - * Reads Sourcepoint consent from localStorage and mirrors it into - * `__gpp` and `__gpp_sid` cookies for Trusted Server to read. - * - * Sourcepoint stores different shapes depending on the campaign/module. US - * National data is commonly stored under `usnat.consentString` and - * `usnat.applicableSections`, while some setups expose `gppData.gppString`. - * - * Returns `true` if cookies were written, `false` otherwise. - */ -export function mirrorSourcepointConsent(): boolean { - if (typeof localStorage === 'undefined' || typeof document === 'undefined') { - return false; - } - - const consent = findSourcepointConsent(); - if (!consent) { - clearSourcepointCookies(); - log.debug('sourcepoint: no GPP data found in localStorage'); - return false; - } - - const { gppString, applicableSections } = consent; - if (!gppString) { - clearSourcepointCookies(); - log.debug('sourcepoint: gppString is empty'); - return false; - } - - const existingGppCookie = readCookie(GPP_COOKIE_NAME); - if (existingGppCookie && existingGppCookie !== gppString && !hasSourcepointMarker()) { - log.debug('sourcepoint: preserving existing __gpp cookie from another writer'); - return false; - } - - writeCookie(GPP_SOURCE_COOKIE_NAME, GPP_SOURCE_SOURCEPOINT); - writeCookie(GPP_COOKIE_NAME, gppString); - - if (Array.isArray(applicableSections) && applicableSections.length > 0) { - writeCookie(GPP_SID_COOKIE_NAME, applicableSections.join(',')); - } else { - clearCookie(GPP_SID_COOKIE_NAME); - } - - initialRetryDone = true; - clearInitialRetryTimer(); - - log.info('sourcepoint: mirrored GPP consent to cookies', { - gppLength: gppString.length, - sections: applicableSections, - }); - - return true; -} - -/** - * Initializes Sourcepoint consent mirroring and bounded refresh hooks. - */ -export function initializeSourcepointConsentMirror(): void { - if (initialized || typeof window === 'undefined' || typeof document === 'undefined') { - return; - } - - initialized = true; - - if (!mirrorSourcepointConsent()) { - scheduleInitialRetry(); - } - - // Sourcepoint persists consent changes to localStorage. Re-mirror when a - // user returns to the page so session cookies do not remain stale. - document.addEventListener('visibilitychange', mirrorOnVisible); - window.addEventListener('focus', mirrorSourcepointConsent); -} - -initializeSourcepointConsentMirror(); diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/module.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/module.ts new file mode 100644 index 000000000..9e5c715b1 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/module.ts @@ -0,0 +1,106 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; + +import { + disposeSourcepointConsentMirror, + initializeSourcepointConsentMirror, +} from './consent_mirror'; +import { installSourcepointGuard, resetGuardState } from './script_guard'; + +export const SOURCEPOINT_INTEGRATION_ID = 'sourcepoint' as const; + +interface SourcepointBootConfig { + readonly rewriteSdk: boolean; +} + +export interface SourcepointRuntimeDependencies { + readonly initializeConsentMirror: () => void; + readonly installGuard: () => void; + readonly resetConsentMirror: () => void; + readonly resetGuard: () => void; +} + +function sourcepointBootConfig(candidate: unknown): candidate is SourcepointBootConfig { + 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 rewriteSdk = Object.getOwnPropertyDescriptor(candidate, 'rewriteSdk'); + return Boolean( + rewriteSdk?.enumerable && 'value' in rewriteSdk && typeof rewriteSdk.value === 'boolean' + ); + } catch { + return false; + } +} + +/** Own Sourcepoint's optional guard and consent mirror as one release-bound unit. */ +export function createSourcepointRuntime( + dependencies: SourcepointRuntimeDependencies = { + initializeConsentMirror: initializeSourcepointConsentMirror, + installGuard: installSourcepointGuard, + resetConsentMirror: disposeSourcepointConsentMirror, + resetGuard: resetGuardState, + } +): IntegrationLifecycleRuntime { + let active = false; + let guardInstalled = false; + let started = false; + return Object.freeze({ + activate: (candidate: unknown) => { + if (!sourcepointBootConfig(candidate)) { + throw new TypeError('Sourcepoint integration config is invalid'); + } + if (active) throw new Error('Sourcepoint runtime is already active'); + if (candidate.rewriteSdk) { + try { + dependencies.installGuard(); + guardInstalled = true; + } catch (error) { + try { + dependencies.resetGuard(); + } catch { + // Preserve the activation failure after best-effort rollback. + } + throw error; + } + } + active = true; + started = false; + return (): void => { + if (!active) return; + active = false; + started = false; + try { + dependencies.resetConsentMirror(); + } finally { + if (guardInstalled) { + guardInstalled = false; + dependencies.resetGuard(); + } + } + }; + }, + start: (_config: unknown) => { + if (!active || started) return; + started = true; + dependencies.initializeConsentMirror(); + }, + }); +} + +export function createSourcepointIntegrationRegistration(release: string): IntegrationRegistration { + return createLifecycleIntegrationRegistration(SOURCEPOINT_INTEGRATION_ID, release, { + validateConfig: sourcepointBootConfig, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/testlight/module.ts b/crates/trusted-server-js/lib/src/integrations/testlight/module.ts new file mode 100644 index 000000000..dbbab6951 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/testlight/module.ts @@ -0,0 +1,224 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; +import { log } from '../../core/log'; + +export const TESTLIGHT_INTEGRATION_ID = 'testlight' as const; + +interface TestlightGlobal { + que?: unknown[] | undefined; +} + +interface TestlightTarget { + testlight?: TestlightGlobal | undefined; +} + +export interface TestlightRuntimeDependencies { + readonly enqueue: (callback: () => void) => void; + readonly started: () => void; + readonly target: TestlightTarget; +} + +function callableQueue(candidate: unknown): candidate is { push: (entry: unknown) => number } { + return ( + (typeof candidate === 'object' || typeof candidate === 'function') && + candidate !== null && + typeof (candidate as { push?: unknown }).push === 'function' + ); +} + +function ownQueueValues(candidate: unknown): unknown[] { + if (!Array.isArray(candidate)) return []; + const entries: Array = []; + try { + for (const key of Reflect.ownKeys(candidate)) { + if (typeof key !== 'string' || !/^(0|[1-9][0-9]*)$/.test(key)) continue; + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || index >= 4_294_967_295) continue; + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (descriptor && descriptor.enumerable && 'value' in descriptor) { + entries.push([index, descriptor.value]); + } + } + } catch { + return []; + } + entries.sort(([left], [right]) => left - right); + return entries.map(([, value]) => value); +} + +/** Own Testlight's callback bridge without retaining callbacks after TSJS commit. */ +export function createTestlightRuntime( + dependencies: TestlightRuntimeDependencies = { + enqueue: (callback) => { + const queue = (window as typeof window & { tsjs?: { que?: unknown } }).tsjs?.que; + if (!callableQueue(queue)) throw new Error('Testlight TSJS queue is unavailable'); + queue.push(callback); + }, + started: () => log.info('Testlight integration initialized'), + target: window as typeof window & TestlightTarget, + } +): IntegrationLifecycleRuntime { + let active = false; + let started = false; + let ownedGlobal: TestlightGlobal | undefined; + let installedQueue: unknown[] | undefined; + let previousQueueDescriptor: PropertyDescriptor | undefined; + let previousTargetDescriptor: PropertyDescriptor | undefined; + let createdGlobal = false; + let forwarding = false; + let originalQueue: unknown[] | undefined; + let originalQueueLength = 0; + + const releaseOwnership = (): void => { + const global = ownedGlobal; + const queue = installedQueue; + const queueDescriptor = previousQueueDescriptor; + const targetDescriptor = previousTargetDescriptor; + const removeGlobal = createdGlobal; + const restoreQueue = originalQueue; + const restoreQueueLength = originalQueueLength; + const shouldReturnPending = !forwarding; + ownedGlobal = undefined; + installedQueue = undefined; + previousQueueDescriptor = undefined; + previousTargetDescriptor = undefined; + createdGlobal = false; + forwarding = false; + originalQueue = undefined; + originalQueueLength = 0; + + if (global && queue) { + try { + if (Object.getOwnPropertyDescriptor(global, 'que')?.value === queue) { + if (shouldReturnPending && restoreQueue) { + const later = ownQueueValues(queue).slice(restoreQueueLength); + Array.prototype.push.apply(restoreQueue, later); + } + if (queueDescriptor) Object.defineProperty(global, 'que', queueDescriptor); + else Reflect.deleteProperty(global, 'que'); + } + } catch { + // Publisher replacement wins over cleanup. + } + } + if (removeGlobal) { + try { + if ( + Object.getOwnPropertyDescriptor(dependencies.target, 'testlight')?.value === global && + global && + Reflect.ownKeys(global).length === 0 + ) { + if (targetDescriptor) { + Object.defineProperty(dependencies.target, 'testlight', targetDescriptor); + } else { + Reflect.deleteProperty(dependencies.target, 'testlight'); + } + } + } catch { + // Publisher replacement wins over cleanup. + } + } + }; + + return Object.freeze({ + activate: (_config: unknown): (() => void) => { + if (active) throw new Error('Testlight runtime is already active'); + try { + previousTargetDescriptor = Object.getOwnPropertyDescriptor( + dependencies.target, + 'testlight' + ); + if (previousTargetDescriptor && !('value' in previousTargetDescriptor)) { + throw new TypeError('Testlight publisher global accessor is unsupported'); + } + const currentGlobal = previousTargetDescriptor?.value; + const global = + typeof currentGlobal === 'object' && currentGlobal !== null ? currentGlobal : {}; + createdGlobal = global !== currentGlobal; + if (createdGlobal) dependencies.target.testlight = global; + ownedGlobal = global; + + previousQueueDescriptor = Object.getOwnPropertyDescriptor(global, 'que'); + if (previousQueueDescriptor && !('value' in previousQueueDescriptor)) { + throw new TypeError('Testlight publisher queue accessor is unsupported'); + } + originalQueue = + previousQueueDescriptor && + 'value' in previousQueueDescriptor && + Array.isArray(previousQueueDescriptor.value) + ? previousQueueDescriptor.value + : undefined; + const queue = ownQueueValues(originalQueue); + originalQueueLength = queue.length; + Object.defineProperty(global, 'que', { + configurable: true, + enumerable: true, + value: queue, + writable: true, + }); + installedQueue = queue; + forwarding = false; + active = true; + started = false; + } catch (error) { + releaseOwnership(); + throw error; + } + return (): void => { + if (!active) return; + active = false; + started = false; + releaseOwnership(); + }; + }, + start: (_config: unknown): void => { + if (!active || started) return; + started = true; + dependencies.started(); + + try { + const queue = installedQueue; + if ( + !queue || + !ownedGlobal || + Object.getOwnPropertyDescriptor(ownedGlobal, 'que')?.value !== queue + ) { + return; + } + const pending = ownQueueValues(queue); + queue.length = 0; + Object.defineProperty(queue, 'push', { + configurable: true, + enumerable: false, + value: (...candidates: unknown[]): number => { + for (const candidate of candidates) { + if (typeof candidate !== 'function') continue; + try { + dependencies.enqueue(candidate as () => void); + log.debug('testlight shim: flushed callback'); + } catch (error) { + log.debug('testlight shim: queued callback threw', error); + } + } + return 0; + }, + writable: false, + }); + forwarding = true; + for (const candidate of pending) queue.push(candidate); + } catch (error) { + releaseOwnership(); + throw error; + } + }, + }); +} + +export function createTestlightIntegrationRegistration(release: string): IntegrationRegistration { + return createLifecycleIntegrationRegistration(TESTLIGHT_INTEGRATION_ID, release, { + validateConfig: (candidate) => candidate === undefined, + }); +} diff --git a/crates/trusted-server-js/lib/src/kernel/lifecycle_module.ts b/crates/trusted-server-js/lib/src/kernel/lifecycle_module.ts new file mode 100644 index 000000000..355b6cd32 --- /dev/null +++ b/crates/trusted-server-js/lib/src/kernel/lifecycle_module.ts @@ -0,0 +1,132 @@ +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from './integration_registry'; + +const MAX_CONFIG_DEPTH = 16; +const MAX_CONFIG_NODES = 512; +const MAX_CONFIG_MEMBERS = 256; + +export interface IntegrationLifecycleRuntime { + readonly activate: (config: unknown) => () => void; + readonly start: (config: unknown) => void; +} + +export interface LifecycleIntegrationRegistrationOptions { + readonly validateConfig?: (candidate: unknown) => boolean; +} + +function validFrozenConfig(candidate: unknown): boolean { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + !Object.isFrozen(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype + ) { + return false; + } + const visited = new Set(); + let nodes = 0; + const visit = (value: unknown, depth: number): boolean => { + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return true; + if (typeof value === 'function' || depth > MAX_CONFIG_DEPTH || visited.has(value)) return false; + if (nodes >= MAX_CONFIG_NODES || !Object.isFrozen(value)) return false; + nodes += 1; + visited.add(value); + const isArray = Array.isArray(value); + if (!isArray && Object.getPrototypeOf(value) !== Object.prototype) return false; + const keys = Reflect.ownKeys(value); + if (keys.length > MAX_CONFIG_MEMBERS + (isArray ? 1 : 0)) return false; + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== 'string') return false; + if (isArray && key === 'length') continue; + if (isArray && key !== String(index)) return false; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return false; + if (!visit(descriptor.value, depth + 1)) return false; + } + return true; + }; + try { + return visit(candidate, 0); + } catch { + return false; + } +} + +function readRuntime( + id: string, + interfaces: Readonly> +): IntegrationLifecycleRuntime | undefined { + try { + const descriptor = Object.getOwnPropertyDescriptor(interfaces, id); + if (!descriptor || !('value' in descriptor)) return undefined; + const runtime = descriptor.value; + if ( + typeof runtime !== 'object' || + runtime === null || + Array.isArray(runtime) || + !Object.isFrozen(runtime) || + Reflect.ownKeys(runtime).length !== 2 + ) { + return undefined; + } + const activate = Object.getOwnPropertyDescriptor(runtime, 'activate'); + const start = Object.getOwnPropertyDescriptor(runtime, 'start'); + if ( + !activate || + !('value' in activate) || + typeof activate.value !== 'function' || + !start || + !('value' in start) || + typeof start.value !== 'function' + ) { + return undefined; + } + return runtime as IntegrationLifecycleRuntime; + } catch { + return undefined; + } +} + +/** Build a release-bound registration around one exact composition-owned runtime. */ +export function createLifecycleIntegrationRegistration( + id: string, + release: string, + options: LifecycleIntegrationRegistrationOptions = {} +): IntegrationRegistration { + return Object.freeze({ + id, + release, + prepare: async ({ config, interfaces }: IntegrationPrepareContext) => { + const validateConfig = options.validateConfig ?? validFrozenConfig; + let configValid: boolean; + try { + configValid = validateConfig(config); + } catch { + configValid = false; + } + if (!configValid) { + throw new TypeError(`${id} integration config is invalid`); + } + const runtime = readRuntime(id, interfaces); + if (!runtime) throw new TypeError(`${id} integration runtime is unavailable`); + + return Object.freeze({ + activate: ({ afterCommit, onDispose }: IntegrationActivationContext) => { + const runtimeRelease: { value?: () => void } = {}; + onDispose(() => runtimeRelease.value?.()); + const releaseRuntime = runtime.activate(config); + if (typeof releaseRuntime !== 'function') { + throw new TypeError(`${id} integration disposer is unavailable`); + } + runtimeRelease.value = releaseRuntime; + afterCommit(() => runtime.start(config)); + }, + }); + }, + }); +} 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..dd7a40b47 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/datadome/module.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createDataDomeIntegrationRegistration, + createDataDomeRuntime, +} from '../../../src/integrations/datadome/module'; +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.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', () => { + it('prepares inertly, activates before publication, and releases exactly once', async () => { + const order: string[] = []; + const release = vi.fn(() => order.push('release')); + const runtime = Object.freeze({ + activate: vi.fn(() => { + order.push('datadome:activate'); + return release; + }), + start: vi.fn(() => order.push('datadome:start')), + }); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + integrations: [{ id: 'datadome', required: true }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['datadome']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: undefined, + interfaces: Object.freeze({ datadome: runtime }), + }), + }); + registry.register(createDataDomeIntegrationRegistration(RELEASE_ID)); + + expect(runtime.activate).not.toHaveBeenCalled(); + expect(runtime.start).not.toHaveBeenCalled(); + const result = await registry.install(callbacks(order)); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['core', 'datadome:activate', 'publish', 'datadome:start', 'drain']); + if (result.state === 'kernel') { + result.dispose(); + result.dispose(); + } + expect(release).toHaveBeenCalledOnce(); + }); + + 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, + integrations: [{ id: 'datadome', required: true }], + }, + 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/module.test.ts b/crates/trusted-server-js/lib/test/integrations/didomi/module.test.ts new file mode 100644 index 000000000..2a78398fa --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/didomi/module.test.ts @@ -0,0 +1,81 @@ +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); + +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, + integrations: [{ id: 'didomi', required: true }], + }, + 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..ae835d668 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/google_tag_manager/module.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest'; + +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); + +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', () => { + it('activates both guards before publication and releases them in reverse order', async () => { + const order: string[] = []; + const runtime = createGoogleTagManagerRuntime({ + installBeaconGuard: () => order.push('beacon:install'), + installScriptGuard: () => order.push('script:install'), + resetBeaconGuard: () => order.push('beacon:reset'), + resetScriptGuard: () => order.push('script:reset'), + started: () => order.push('gtm:start'), + }); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + integrations: [{ id: 'google_tag_manager', required: true }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['google_tag_manager']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: undefined, + interfaces: Object.freeze({ google_tag_manager: runtime }), + }), + }); + 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', + 'gtm:start', + '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/lifecycle_modules.test.ts b/crates/trusted-server-js/lib/test/integrations/lifecycle_modules.test.ts new file mode 100644 index 000000000..5bef06a62 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/lifecycle_modules.test.ts @@ -0,0 +1,117 @@ +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'; + +const RELEASE_ID = 'a'.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', createOsanoIntegrationRegistration] as const, + ['permutive', createPermutiveIntegrationRegistration] as const, + ['sourcepoint', 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') return Object.freeze({ rewriteSdk: true }); + return undefined; +}; + +describe('remaining integration lifecycle modules', () => { + it('activates a maximal manifest once in order and disposes it in exact reverse order', async () => { + const order: string[] = []; + const ids = Object.freeze(registrations.map(([id]) => id)); + const interfaces = Object.freeze( + Object.fromEntries( + ids.map((id) => [ + id, + Object.freeze({ + activate: (config: unknown) => { + expect(config).toEqual(configFor(id)); + order.push(`activate:${id}`); + return () => order.push(`dispose:${id}`); + }, + start: () => order.push(`start:${id}`), + }), + ]) + ) + ); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + integrations: ids.map((id) => ({ id, required: true })), + }, + releaseId: RELEASE_ID, + knownIntegrationIds: ids, + startedAtMs: 0, + now: () => 0, + 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', + ...ids.map((id) => `activate:${id}`), + 'publish', + ...ids.map((id) => `start:${id}`), + 'drain', + ]); + if (result.state === 'kernel') result.dispose(); + expect(order.slice(-ids.length)).toEqual([...ids].reverse().map((id) => `dispose:${id}`)); + }); + + 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, + integrations: [{ id, required: true }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze([id]), + startedAtMs: 0, + now: () => 0, + 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).toHaveBeenCalledOnce(); + expect(start).toHaveBeenCalledOnce(); + } + ); +}); 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..d44677b10 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts @@ -0,0 +1,87 @@ +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); + }); +}); 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 811be1f38..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 & { @@ -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/sourcepoint/module.test.ts b/crates/trusted-server-js/lib/test/integrations/sourcepoint/module.test.ts new file mode 100644 index 000000000..8be84e35d --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/sourcepoint/module.test.ts @@ -0,0 +1,68 @@ +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); + +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, + integrations: [{ id: 'sourcepoint', required: true }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['sourcepoint']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ + sourcepoint: 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..a18e6e199 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts @@ -0,0 +1,104 @@ +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('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/lifecycle_module.test.ts b/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts new file mode 100644 index 000000000..97d93a0b6 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts @@ -0,0 +1,94 @@ +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); + +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, + integrations: [{ id: 'example', required: true }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['example']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ example: 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('example', 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('example', 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('example', RELEASE_ID)); + + await expect(owner.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + }); +}); From 079f07f7f39f001188820ea1d82ba88d02ddd634 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:40:52 -0700 Subject: [PATCH 130/194] Compose remaining integration runtimes --- .../lib/src/composition/browser.ts | 94 ++++++++++++++++++- .../lib/test/composition/browser.test.ts | 87 +++++++++++++++++ 2 files changed, 179 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index c8dd23d22..8d8fda9c1 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -46,6 +46,9 @@ 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, @@ -69,6 +72,11 @@ import { 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 { createDiagnosticsBus, @@ -83,7 +91,12 @@ import type { import { createRuntimeSession } from '../kernel/sessions'; import type { CoreActivationContext } from '../kernel/integration_registry'; import { createRuntime, type Runtime, type RuntimeOptions } from '../kernel/runtime'; -import { createAuctionContextRegistry, type AuctionContextRegistry } from '../services/context'; +import { + createAuctionContextRegistry, + type AuctionContextContributor, + type AuctionContextRegistry, + type ContextContributorOwner, +} from '../services/context'; import { createAuctionBatchService, type AuctionBatchFetcher, @@ -207,7 +220,9 @@ interface AcceptedBrowserBoot { readonly cachePolicy?: unknown; readonly creative: Readonly; readonly diagnostics: Readonly; + readonly didomi?: unknown; readonly manifest: Readonly; + readonly sourcepoint?: unknown; } interface PreparedBrowserServices { @@ -225,6 +240,38 @@ function projectionSlots(projection: object): readonly string[] { return Object.freeze(accepted.auction.results.map(({ slot }) => slot)); } +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. * @@ -301,6 +348,7 @@ export function createTestBrowserRuntimeComposition( let gptDiagnosticsFacts: GptDiagnosticsFactBuffer | undefined; let gptDiagnosticsRuntime: GptDiagnosticsRuntime | undefined; let renderTrace: RenderTraceRuntimeOwner | undefined; + let acceptedBrowserBoot: AcceptedBrowserBoot | undefined; const consumeCoreObservation = (observation: DiagnosticsObservation): void => { if ( observation['kind'] !== 'render_attempt' || @@ -370,6 +418,10 @@ export function createTestBrowserRuntimeComposition( }, start: startGpt, }); + const gptIntegrationRuntime = Object.freeze({ + activate: gptRuntime.activate, + start: gptRuntime.start, + }); let runtimeSession: RuntimeSession | undefined; let prebidCoordinator: PrebidSelectionCoordinator | undefined; const startPrebid = compositionOptions.prebidStartupForTest ?? (() => undefined); @@ -451,6 +503,8 @@ export function createTestBrowserRuntimeComposition( } if (id === 'creative' && config === undefined) config = creativeBoot; if (id === 'gpt_diagnostics' && config === undefined) config = diagnosticsBoot?.gpt; + if (id === 'didomi' && config === undefined) config = acceptedBrowserBoot?.didomi; + if (id === 'sourcepoint' && config === undefined) config = acceptedBrowserBoot?.sourcepoint; const interfaces = runtimeSession?.interfaces; if (!interfaces) throw new Error(`Integration interfaces are unavailable for ${id}`); return Object.freeze({ @@ -460,6 +514,32 @@ export function createTestBrowserRuntimeComposition( }; let preparedBrowserServices: PreparedBrowserServices | undefined; let auctionContextRegistry: AuctionContextRegistry | undefined; + const dataDomeRuntime = createDataDomeRuntime(); + const didomiRuntime = createDidomiRuntime(); + const googleTagManagerRuntime = createGoogleTagManagerRuntime(); + const lockrRuntime = createLockrRuntime(); + const osanoRuntime = createOsanoRuntime(); + const permutiveRuntime = createPermutiveRuntime({ + registerContext: (contributor) => { + const registry = auctionContextRegistry; + const owner = runtimeSession; + return registry && owner + ? registerScopedContextContributor(registry, owner, 'permutive', contributor) + : undefined; + }, + }); + const sourcepointRuntime = 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; let projectionParser: ((candidate: unknown) => object | undefined) | undefined; const frozenSlotResult = (result: Record): Readonly> => @@ -649,6 +729,7 @@ export function createTestBrowserRuntimeComposition( }, prepareOwner: (context) => { const boot = context.boot as unknown as AcceptedBrowserBoot; + acceptedBrowserBoot = boot; creativeBoot = boot.creative; diagnosticsBoot = boot.diagnostics; const cachePolicy = @@ -889,12 +970,20 @@ export function createTestBrowserRuntimeComposition( interfaces: Object.freeze({ adapters: composition.adapters, creative: creativeRuntime, + datadome: dataDomeRuntime, diagnostics: Object.freeze({ subscribe: preparedDiagnosticsBus.subscribe }), + didomi: didomiRuntime, + google_tag_manager: googleTagManagerRuntime, ...(preparedGptDiagnosticsRuntime ? { gpt_diagnostics: preparedGptDiagnosticsRuntime } : {}), - gpt: gptRuntime, + gpt: gptIntegrationRuntime, + lockr: lockrRuntime, + osano: osanoRuntime, + permutive: permutiveRuntime, prebid: prebidRuntime, + sourcepoint: sourcepointRuntime, + testlight: testlightRuntime, ...services, }), onNavigationDispose: (navigationGeneration) => @@ -917,6 +1006,7 @@ export function createTestBrowserRuntimeComposition( auctionBatchService = undefined; auctionContextRegistry = undefined; projectionParser = undefined; + acceptedBrowserBoot = undefined; creativeBoot = undefined; diagnosticsBoot = undefined; } diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index c5af73d33..344444e8b 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -31,10 +31,18 @@ import { import { log as localLog } from '../../src/core/log'; import type { BrowserAuctionBidV1 } from '../../src/core/types'; 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 { createGptIntegrationRegistration } from '../../src/integrations/gpt/module'; 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 { createPermutiveIntegrationRegistration } from '../../src/integrations/permutive/module'; import { createPrebidIntegrationRegistration } from '../../src/integrations/prebid/module'; +import { createSourcepointIntegrationRegistration } from '../../src/integrations/sourcepoint/module'; +import { createTestlightIntegrationRegistration } from '../../src/integrations/testlight/module'; import { publicLog } from '../../src/kernel/fallback'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; import { @@ -990,6 +998,85 @@ describe('browser composition', () => { expect(isGuardInstalled()).toBe(false); }); + it('owns every remaining integration in one maximal composed transaction', async () => { + vi.useFakeTimers(); + const releaseId = 'a'.repeat(64); + const target = {}; + const members = Object.freeze([ + ['datadome', createDataDomeIntegrationRegistration] as const, + ['didomi', createDidomiIntegrationRegistration] as const, + ['google_tag_manager', createGoogleTagManagerIntegrationRegistration] as const, + ['lockr', createLockrIntegrationRegistration] as const, + ['osano', createOsanoIntegrationRegistration] as const, + ['permutive', createPermutiveIntegrationRegistration] as const, + ['sourcepoint', createSourcepointIntegrationRegistration] as const, + ['testlight', createTestlightIntegrationRegistration] as const, + ]); + 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') return Object.freeze({ rewriteSdk: true }); + return undefined; + }; + 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: { + version: 1, + releaseId, + integrations: ids.map((id) => ({ id, required: true })), + }, + knownIntegrationIds: ids, + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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() }, + } + ); + + expect(composition.runtime.start()).toBe(true); + for (const [, createRegistration] of members) { + expect(composition.runtime.registerIntegration(createRegistration(releaseId))).toBe(true); + } + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(composition.auctionContextRegistryForTest()?.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: ['permutive'], + }); + expect(composition.runtimeSessionForTest()?.interfaces).toMatchObject( + Object.fromEntries(ids.map((id) => [id, expect.any(Object)])) + ); + 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); + }); + it('injects the exact creative boot into reversible activation and post-commit startup', async () => { const releaseId = 'a'.repeat(64); const creative = Object.freeze({ From cd13e230510cabff3558893330729fdf16bea112 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:42:02 -0700 Subject: [PATCH 131/194] Build the Prebid refresh policy boundary --- .../lib/build-prebid-external.mjs | 11 +- .../lib/src/adapters/googletag.ts | 63 ++- .../lib/src/adapters/prebid.ts | 4 + .../lib/src/composition/browser.ts | 40 +- .../lib/src/integrations/gpt/startup.ts | 49 +- .../lib/src/integrations/prebid/module.ts | 453 ++++++++++++++++++ .../lib/src/integrations/prebid/startup.ts | 55 ++- .../lib/test/adapters/googletag.test.ts | 74 +++ .../lib/test/adapters/prebid.test.ts | 4 + .../lib/test/composition/browser.test.ts | 14 +- .../lib/test/integrations/gpt/startup.test.ts | 68 ++- .../test/integrations/prebid/module.test.ts | 426 +++++++++++++++- .../test/integrations/prebid/startup.test.ts | 123 +++++ .../test/prebid-artifact-integration.test.mjs | 126 ++++- 14 files changed, 1488 insertions(+), 22 deletions(-) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index 7f972da5a..9d0b14aa4 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -303,16 +303,19 @@ function renderExternalWrapper(bundleCode, stamp) { '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;i256||(previous!==undefined&&previous>=current))return false;previous=current;}return true;}', + 'function __tsString(value,max,lowercase){if(typeof value!=="string"||value.length===0||(lowercase&&value!==value.toLowerCase()))return false;var bytes=0;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)||!__tsSortedStrings(sources,64))return false;for(var k=0;k=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 + | Readonly<{ + action: 'defer'; + slots: readonly object[]; + completion: PromiseLike; + admission?: GoogletagPublisherCallAdmission; + }> | Readonly<{ action: 'suppress' }>; } @@ -139,10 +145,12 @@ export interface GoogletagPublisherDisplayCall { 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; display(slot: string | object): unknown; @@ -533,6 +541,7 @@ function createFacade( } }; 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]), @@ -2105,6 +2114,7 @@ export function createBrowserGoogletagAdapter( return undefined; } }; + const deferredRefreshes = new Set<() => void>(); const restorers: Array<() => void> = []; const install = ( external: object, @@ -2188,7 +2198,11 @@ export function createBrowserGoogletagAdapter( let decision: ReturnType>; try { decision = refreshObserver( - Object.freeze({ requestedSlots: requested, slots: effective }) + Object.freeze({ + requestedSlots: requested, + slots: effective, + options: arguments_[1], + }) ); } catch { // Observer failure must leave the publisher call native. @@ -2212,6 +2226,51 @@ export function createBrowserGoogletagAdapter( 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); } } @@ -2247,6 +2306,8 @@ export function createBrowserGoogletagAdapter( } 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); diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index 26b7dfe1c..5b9863d5a 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -132,6 +132,7 @@ export interface PrebidFacade { ): () => 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 @@ -537,6 +538,7 @@ const REQUIRED_API_METHODS = [ 'registerBidAdapter', 'renderAd', 'requestBids', + 'setTargetingForGPTAsync', ] as const; function commandQueue(binding: object): CommandQueue | undefined { @@ -1083,6 +1085,8 @@ export function createBrowserPrebidAdapter( 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 diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 8d8fda9c1..20f82162e 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -26,7 +26,11 @@ import type { CreativeBootV1, DiagnosticsBootV1, } from '../core/types'; -import { createRenderTrace, type RenderTraceRuntimeOwner } from '../core/trace'; +import { + createRenderTrace, + isEffectivelyVisible, + type RenderTraceRuntimeOwner, +} from '../core/trace'; import { parseBidRenderSourceV1, parseBrowserAuctionProjectionV1, @@ -354,7 +358,8 @@ export function createTestBrowserRuntimeComposition( observation['kind'] !== 'render_attempt' || typeof observation['slotId'] !== 'string' || (observation['path'] !== 'auction' && observation['path'] !== 'ssat') || - typeof observation['rendered'] !== 'boolean' + typeof observation['rendered'] !== 'boolean' || + typeof observation['injected'] !== 'boolean' ) { return; } @@ -373,10 +378,37 @@ export function createTestBrowserRuntimeComposition( 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'); renderTrace?.record({ - slotId: observation['slotId'], + 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 { @@ -742,7 +774,9 @@ export function createTestBrowserRuntimeComposition( ); if (!initialProjection) throw new Error('Accepted boot projection is unavailable'); const preparedRenderTrace = createRenderTrace({ + ...(typeof document === 'undefined' ? {} : { document }), onSubscriberError: (error) => log.warn('render diagnostics: subscriber failed', error), + overlayEnabled: boot.diagnostics.renderTraceOverlay, }); const preparedDiagnosticsBus = createDiagnosticsBus({ manifest: boot.manifest, diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts b/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts index 0a63d88ad..ed3399e1c 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts @@ -19,9 +19,17 @@ type GptPublisherSlotBoundary = Pick< export interface GptStartup { readonly activate: () => () => void; + readonly installRefreshPolicy: (policy: GptRefreshPolicy) => (() => void) | undefined; readonly start: (config: unknown) => void; } +/** One optional Prebid policy composed into the sole publisher refresh observer. */ +export interface GptRefreshPolicy { + readonly prepare: ( + call: Readonly + ) => PromiseLike | undefined; +} + export interface GptStartupOptions { readonly googletag: Pick; readonly slots: () => GptPublisherSlotBoundary; @@ -30,6 +38,7 @@ export interface GptStartupOptions { /** Join the sole GPT interception boundary to runtime-owned slot handoff state. */ export function createGptStartup(options: GptStartupOptions): GptStartup { + let refreshPolicy: GptRefreshPolicy | undefined; return Object.freeze({ activate: (): (() => void) => { const slots = options.slots(); @@ -44,11 +53,47 @@ export function createGptStartup(options: GptStartupOptions): GptStartup { }, display: (call: Readonly) => slots.preparePublisherDisplay(call), - refresh: (call: Readonly) => - slots.preparePublisherRefresh(call), + refresh: (call: Readonly) => { + const decision = slots.preparePublisherRefresh(call); + const policy = refreshPolicy; + if (!policy || decision.action === 'suppress') return decision; + const policySlots = decision.action === 'replace' ? decision.slots : call.slots; + const policyCall = + decision.action === 'replace' + ? Object.freeze({ + requestedSlots: + call.requestedSlots === undefined ? undefined : Object.freeze([...policySlots]), + slots: Object.freeze([...policySlots]), + options: call.options, + }) + : call; + let completion: PromiseLike | undefined; + try { + completion = policy.prepare(policyCall); + } catch { + return decision; + } + if (!completion) return decision; + return Object.freeze({ + action: 'defer' as const, + ...(decision.admission ? { admission: decision.admission } : {}), + completion, + slots: Object.freeze([...policySlots]), + }); + }, }); return options.googletag.observePublisherCalls(observer); }, + installRefreshPolicy: (policy: GptRefreshPolicy): (() => void) | undefined => { + if (!policy || typeof policy.prepare !== 'function' || refreshPolicy) return undefined; + refreshPolicy = policy; + let active = true; + return (): void => { + if (!active) return; + active = false; + if (refreshPolicy === policy) refreshPolicy = undefined; + }; + }, start: (config: unknown): void => { options.slots().start(); options.start?.(config); diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 6f28fc04c..9efe63408 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -7,9 +7,16 @@ import { import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../core/types'; import { PrebidAdmissionContractError, + type PrebidAdapter, type PrebidEventFacade, + type PrebidOperation, type PreparedTrustedBidV1, } from '../../adapters/prebid'; +import type { + GoogletagAdapter, + GoogletagOperation, + GoogletagPublisherRefreshCall, +} from '../../adapters/googletag'; import type { IntegrationActivationContext, IntegrationPrepareContext, @@ -159,6 +166,452 @@ export function createPrebidIntegrationRegistration(release: string): Integratio }); } +const PREBID_REFRESH_TIMEOUT_MS = 1_500; +const MAX_PREBID_REFRESH_AD_UNITS = 64; +const PREBID_REFRESH_TARGETING_KEYS = Object.freeze([ + 'ts_initial', + 'hb_pb', + 'hb_bidder', + 'hb_adid', + 'hb_cache_host', + 'hb_cache_path', +]); + +export interface PrebidRefreshPolicyOptions { + readonly currentNavigation: () => NavigationSession | undefined; + readonly excludedGamAdUnitPathSuffixes: readonly string[] | (() => readonly string[]); + readonly googletag: Pick; + readonly runSyntheticAuction: ( + slots: readonly object[], + navigation: NavigationSession + ) => PrebidRefreshAuctionOperation; +} + +export interface PrebidRefreshAuctionPreparation { + readonly adUnitCodes: readonly string[]; + readonly adUnits: readonly object[]; +} + +export interface PrebidRefreshAuctionOperation { + readonly completion: Promise; + readonly dispose: () => void; +} + +export interface PrebidSyntheticRefreshRunnerOptions { + readonly prebid: Pick; + readonly prepareAuction: (slots: readonly object[], navigation: NavigationSession) => unknown; + readonly scheduler?: RenderScheduler; +} + +export type PrebidSyntheticRefreshRunner = ( + slots: readonly object[], + navigation: NavigationSession +) => PrebidRefreshAuctionOperation; + +export interface PrebidRefreshPolicy { + readonly dispose: () => void; + readonly prepare: ( + call: Readonly + ) => PromiseLike | undefined; +} + +interface PrebidRefreshNavigationOwner { + active: boolean; + readonly navigation: NavigationSession; + readonly pending: Set; +} + +interface PrebidPendingRefresh { + active: boolean; + auctionOperation: PrebidRefreshAuctionOperation | undefined; + readonly owner: PrebidRefreshNavigationOwner; + operation: GoogletagOperation | undefined; + readonly resolve: () => void; + readonly settle: () => void; +} + +function defaultRefreshScheduler(): RenderScheduler { + return Object.freeze({ + clear: (handle: unknown): void => { + globalThis.clearTimeout(handle as ReturnType); + }, + set: (callback: () => void, milliseconds: number): unknown => + globalThis.setTimeout(callback, milliseconds), + }); +} + +function validRefreshAuctionPreparation( + candidate: unknown +): PrebidRefreshAuctionPreparation | undefined { + try { + const record = ownDataObject(candidate); + if (!record || !Array.isArray(record.adUnits) || !Array.isArray(record.adUnitCodes)) { + return undefined; + } + if ( + !Object.isFrozen(record.adUnits) || + !Object.isFrozen(record.adUnitCodes) || + record.adUnits.length === 0 || + record.adUnits.length > MAX_PREBID_REFRESH_AD_UNITS || + record.adUnits.length !== record.adUnitCodes.length + ) { + return undefined; + } + const codes = new Set(); + for (let index = 0; index < record.adUnits.length; index += 1) { + const code = record.adUnitCodes[index]; + const adUnit = ownDataObject(record.adUnits[index]); + if (!validBoundedString(code, 128) || codes.has(code) || !adUnit || adUnit.code !== code) { + return undefined; + } + codes.add(code); + } + return record as unknown as PrebidRefreshAuctionPreparation; + } catch { + return undefined; + } +} + +/** Run one synthetic refresh auction through the exact current Prebid adapter binding. */ +export function createPrebidSyntheticRefreshRunner( + options: PrebidSyntheticRefreshRunnerOptions +): PrebidSyntheticRefreshRunner { + const scheduler = options.scheduler ?? defaultRefreshScheduler(); + return (slots, navigation): PrebidRefreshAuctionOperation => { + let active = true; + let adapterOperation: PrebidOperation | undefined; + let timer: unknown; + let timerArmed = false; + let resolveCompletion!: () => void; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + const settle = (): void => { + if (!active) return; + active = false; + if (timerArmed) { + timerArmed = false; + try { + scheduler.clear(timer); + } catch { + // The runner's logical completion remains terminal. + } + } + const operation = adapterOperation; + adapterOperation = undefined; + try { + operation?.dispose(); + } catch { + // Adapter cleanup cannot prevent the deferred GPT call from resuming. + } + resolveCompletion(); + }; + const handle = Object.freeze({ completion, dispose: settle }); + + let prepared: PrebidRefreshAuctionPreparation | undefined; + try { + if (!navigation.isCurrent()) { + settle(); + return handle; + } + prepared = validRefreshAuctionPreparation( + options.prepareAuction(Object.freeze([...slots]), navigation) + ); + } catch { + prepared = undefined; + } + if (!prepared) { + settle(); + return handle; + } + + try { + const codes = Object.freeze([...prepared.adUnitCodes]); + const adUnits = Object.freeze([...prepared.adUnits]); + const operation = options.prebid.run( + (prebid) => + new Promise((resolveRequest) => { + let requestActive = true; + const finishRequest = (applyTargeting: boolean): void => { + if (!requestActive) return; + requestActive = false; + if (timerArmed) { + timerArmed = false; + try { + scheduler.clear(timer); + } catch { + // The request completion latch remains terminal. + } + } + if (active && applyTargeting) { + try { + prebid.setTargetingForGpt(codes); + } catch { + // Targeting failure still resumes the exact deferred GPT request. + } + } + resolveRequest(); + }; + try { + prebid.requestBids( + Object.freeze({ + adUnits, + bidsBackHandler: () => finishRequest(true), + timeout: PREBID_REFRESH_TIMEOUT_MS, + }) + ); + } catch { + finishRequest(false); + return; + } + if (!requestActive || !active) return; + let installedTimer: unknown; + try { + installedTimer = scheduler.set(() => finishRequest(true), PREBID_REFRESH_TIMEOUT_MS); + if (requestActive && active) { + timer = installedTimer; + timerArmed = true; + } else { + try { + scheduler.clear(installedTimer); + } catch { + // A synchronously-fired timeout is already terminal. + } + } + } catch { + finishRequest(true); + } + }), + Object.freeze({ signal: navigation.signal }) + ); + adapterOperation = operation; + if (!active) { + try { + operation.dispose(); + } catch { + // The runner's exact completion latch has already settled. + } + } else { + void operation.result.then(settle, settle); + } + } catch { + settle(); + } + return handle; + }; +} + +/** Defer one publisher refresh through navigation-owned targeting cleanup and Prebid work. */ +export function createPrebidRefreshPolicy( + options: PrebidRefreshPolicyOptions +): PrebidRefreshPolicy { + const owners = new WeakMap(); + const pending = new Set(); + let disposed = false; + + const currentNavigation = (): NavigationSession | undefined => { + try { + const navigation = options.currentNavigation(); + return navigation?.isCurrent() ? navigation : undefined; + } catch { + return undefined; + } + }; + + const ownerFor = (navigation: NavigationSession): PrebidRefreshNavigationOwner | undefined => { + const current = owners.get(navigation); + if (current?.active) return current; + const owner: PrebidRefreshNavigationOwner = { + active: true, + navigation, + pending: new Set(), + }; + try { + navigation.onDispose('prebid-refresh-policy', () => { + owner.active = false; + owners.delete(navigation); + const snapshot = [...owner.pending]; + for (let index = 0; index < snapshot.length; index += 1) snapshot[index]?.settle(); + }); + } catch { + return undefined; + } + if (!navigation.isCurrent()) return undefined; + owners.set(navigation, owner); + return owner; + }; + + const prepare = ( + call: Readonly + ): PromiseLike | undefined => { + if (disposed) return undefined; + const navigation = currentNavigation(); + if (!navigation) return undefined; + let slots: readonly object[]; + let suffixes: readonly string[]; + try { + if (!Array.isArray(call.slots)) return undefined; + const snapshot: object[] = []; + for (let index = 0; index < call.slots.length; index += 1) { + const slot = call.slots[index]; + if ((typeof slot !== 'object' && typeof slot !== 'function') || slot === null) { + return undefined; + } + snapshot.push(slot); + } + slots = Object.freeze(snapshot); + } catch { + return undefined; + } + try { + const configuredSuffixes = + typeof options.excludedGamAdUnitPathSuffixes === 'function' + ? options.excludedGamAdUnitPathSuffixes() + : options.excludedGamAdUnitPathSuffixes; + suffixes = Object.freeze([...configuredSuffixes]); + } catch { + suffixes = Object.freeze([]); + } + const owner = ownerFor(navigation); + if (!owner) return undefined; + + let resolveCompletion!: () => void; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + const requestReference: { value?: PrebidPendingRefresh } = {}; + const settle = (): void => { + const request = requestReference.value; + if (!request?.active) return; + request.active = false; + pending.delete(request); + request.owner.pending.delete(request); + const operation = request.operation; + request.operation = undefined; + try { + operation?.dispose(); + } catch { + // GPT cleanup failure cannot prevent the publisher refresh from resuming. + } + const auctionOperation = request.auctionOperation; + request.auctionOperation = undefined; + try { + auctionOperation?.dispose(); + } catch { + // Prebid cleanup failure cannot prevent the publisher refresh from resuming. + } + request.resolve(); + }; + const request: PrebidPendingRefresh = { + active: true, + auctionOperation: undefined, + operation: undefined, + owner, + resolve: resolveCompletion, + settle, + }; + requestReference.value = request; + pending.add(request); + owner.pending.add(request); + + try { + const operation = options.googletag.run((gpt) => { + const eligible: object[] = []; + for (let slotIndex = 0; slotIndex < slots.length; slotIndex += 1) { + const slot = slots[slotIndex]; + if (!slot) continue; + let clearFailed = false; + for (let keyIndex = 0; keyIndex < PREBID_REFRESH_TARGETING_KEYS.length; keyIndex += 1) { + try { + gpt.clearTargeting(slot, PREBID_REFRESH_TARGETING_KEYS[keyIndex]); + } catch { + clearFailed = true; + } + } + if (clearFailed) { + eligible.push(slot); + continue; + } + let adUnitPath: unknown; + try { + adUnitPath = gpt.adUnitPath?.(slot); + } catch { + eligible.push(slot); + continue; + } + if (typeof adUnitPath !== 'string') { + eligible.push(slot); + continue; + } + let excluded = false; + for (let suffixIndex = 0; suffixIndex < suffixes.length; suffixIndex += 1) { + if (adUnitPath.endsWith(suffixes[suffixIndex] as string)) { + excluded = true; + break; + } + } + if (!excluded) eligible.push(slot); + } + return Object.freeze(eligible); + }); + request.operation = operation; + if (!request.active) { + try { + operation.dispose(); + } catch { + // The terminal request already resumed GPT. + } + return completion; + } + void operation.result.then((eligible) => { + if ( + !request.active || + !owner.active || + currentNavigation() !== navigation || + !navigation.isCurrent() + ) { + settle(); + return; + } + if (eligible.length === 0) { + settle(); + return; + } + let auction: PrebidRefreshAuctionOperation; + try { + auction = options.runSyntheticAuction(Object.freeze([...eligible]), navigation); + } catch { + settle(); + return; + } + request.auctionOperation = auction; + if (!request.active) { + try { + auction.dispose(); + } catch { + // The policy's completion latch has already settled. + } + return; + } + void auction.completion.then(settle, settle); + }, settle); + } catch { + settle(); + } + return completion; + }; + + return Object.freeze({ + dispose: (): void => { + if (disposed) return; + disposed = true; + const snapshot = [...pending]; + for (let index = 0; index < snapshot.length; index += 1) snapshot[index]?.settle(); + }, + prepare, + }); +} + export type PrebidBidPublicationFailureReason = | 'descriptor_invalid' | 'prebid_admission_failed' diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts b/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts index be5e8fa26..2a7cff812 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts @@ -3,6 +3,13 @@ import type { PrebidEventFacade, PrebidTrustedServerAuctionV1, } from '../../adapters/prebid'; +import type { GoogletagPublisherRefreshCall } from '../../adapters/googletag'; + +interface RefreshPolicyCapability { + readonly prepare: ( + call: Readonly + ) => PromiseLike | undefined; +} export interface PrebidStartup { readonly activate: () => () => void; @@ -14,6 +21,11 @@ export interface PrebidStartupOptions { readonly onAuction: (auction: Readonly) => void; readonly onAuctionEnd: (event: unknown, prebid: Readonly) => void; readonly prebid: Pick; + readonly refresh?: Readonly<{ + readonly configure?: (config: unknown) => void; + readonly install: (policy: RefreshPolicyCapability) => (() => void) | undefined; + readonly policy: RefreshPolicyCapability & Readonly<{ dispose: () => void }>; + }>; readonly start?: (config: unknown) => void; } @@ -26,6 +38,7 @@ export function createPrebidStartup(options: PrebidStartupOptions): PrebidStartu let activationEffects: (() => void) | undefined; let bidderOperation: ReturnType | undefined; let bidderEffects: (() => void) | undefined; + let refreshPolicyRelease: (() => void) | undefined; const retainEffects = ( result: Promise, @@ -63,6 +76,25 @@ export function createPrebidStartup(options: PrebidStartupOptions): PrebidStartu retainEffects(activationOperation.result, (release) => { activationEffects = release; }); + const refresh = options.refresh; + if (refresh) { + try { + refreshPolicyRelease = refresh.install(refresh.policy); + if (!refreshPolicyRelease) throw new Error('Prebid refresh policy is unavailable'); + } catch (error) { + released = true; + try { + disposeOwnedOperation(activationOperation, activationEffects); + } finally { + try { + refresh.policy.dispose(); + } finally { + options.dispose(); + } + } + throw error; + } + } return (): void => { if (released) return; released = true; @@ -72,7 +104,15 @@ export function createPrebidStartup(options: PrebidStartupOptions): PrebidStartu try { disposeOwnedOperation(activationOperation, activationEffects); } finally { - options.dispose(); + try { + options.refresh?.policy.dispose(); + } finally { + try { + refreshPolicyRelease?.(); + } finally { + options.dispose(); + } + } } } }; @@ -80,13 +120,14 @@ export function createPrebidStartup(options: PrebidStartupOptions): PrebidStartu start: (config: unknown): void => { if (!activated || released || started) throw new Error('Prebid startup is unavailable'); started = true; - bidderOperation = options.prebid.run((prebid) => - prebid.registerTrustedServerBidder(options.onAuction) - ); - retainEffects(bidderOperation.result, (release) => { - bidderEffects = release; - }); try { + options.refresh?.configure?.(config); + bidderOperation = options.prebid.run((prebid) => + prebid.registerTrustedServerBidder(options.onAuction) + ); + retainEffects(bidderOperation.result, (release) => { + bidderEffects = release; + }); options.start?.(config); } finally { options.prebid.notifyReady(); diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index f60b509ea..ed701f29c 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -1950,6 +1950,7 @@ describe('browser googletag adapter readiness', () => { if (key === undefined) targeting.clear(); else targeting.delete(key); }), + getAdUnitPath: vi.fn(() => '/publisher/example'), getTargeting: vi.fn((key: string) => targeting.get(key) ?? []), setTargeting: vi.fn((key: string, value: string | readonly string[]) => { targeting.set(key, typeof value === 'string' ? [value] : [...value]); @@ -1964,6 +1965,7 @@ describe('browser googletag adapter readiness', () => { const unsubscribe = gpt.subscribe('slotRequested', listener); gpt.setTargeting(slot, 'hb_adid', 'reservation'); expect(gpt.getTargeting(slot, 'hb_adid')).toEqual(['reservation']); + expect(gpt.adUnitPath?.(slot)).toBe('/publisher/example'); gpt.refresh([slot], { changeCorrelator: false }); expect(gpt.slots()).toEqual([slot]); expect(Object.isFrozen(gpt.slots())).toBe(true); @@ -2098,6 +2100,7 @@ describe('browser googletag adapter readiness', () => { expect(observer.display).not.toHaveBeenCalled(); expect(observer.refresh).toHaveBeenCalledExactlyOnceWith({ + options: { changeCorrelator: true }, requestedSlots: [slot], slots: [slot], }); @@ -2181,6 +2184,77 @@ describe('browser googletag adapter readiness', () => { expect(refreshAdmission.rollback).not.toHaveBeenCalled(); }); + it('defers one explicit refresh and forwards the complete snapshot with exact options once', async () => { + const ready = createReadyGoogletag(); + const first = Object.freeze({ id: 'first' }); + const second = Object.freeze({ id: 'second' }); + const options = Object.freeze({ changeCorrelator: false, publisher: 'exact-options' }); + const originalSlots = [first, second]; + let complete!: () => void; + const completion = new Promise((resolve) => { + complete = resolve; + }); + const admission = Object.freeze({ commit: vi.fn(), rollback: vi.fn() }); + const nativeRefresh = vi.fn(); + ready.pubads.refresh = nativeRefresh; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls({ + refresh: () => + Object.freeze({ + action: 'defer' as const, + admission, + completion, + slots: Object.freeze([first, second]), + }), + }); + + expect(ready.pubads.refresh(originalSlots, options)).toBeUndefined(); + originalSlots.length = 0; + expect(nativeRefresh).not.toHaveBeenCalled(); + + complete(); + await completion; + await Promise.resolve(); + expect(nativeRefresh).toHaveBeenCalledExactlyOnceWith([first, second], options); + expect(admission.commit).toHaveBeenCalledOnce(); + expect(admission.rollback).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(nativeRefresh).toHaveBeenCalledOnce(); + }); + + it('forwards a deferred global refresh exactly once when its observer is released', async () => { + const ready = createReadyGoogletag(); + const first = Object.freeze({ id: 'first' }); + const second = Object.freeze({ id: 'second' }); + const options = Object.freeze({ changeCorrelator: true }); + ready.pubads.getSlots.mockReturnValue([first, second]); + let complete!: () => void; + const completion = new Promise((resolve) => { + complete = resolve; + }); + const nativeRefresh = vi.fn(); + ready.pubads.refresh = nativeRefresh; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const release = adapter.observePublisherCalls({ + refresh: () => + Object.freeze({ + action: 'defer' as const, + completion, + slots: Object.freeze([first, second]), + }), + }); + + ready.pubads.refresh(undefined, options); + expect(nativeRefresh).not.toHaveBeenCalled(); + release(); + expect(nativeRefresh).toHaveBeenCalledExactlyOnceWith([first, second], options); + + complete(); + await completion; + await Promise.resolve(); + expect(nativeRefresh).toHaveBeenCalledOnce(); + }); + it('rolls back each unconsumed publisher admission on native throw and rethrows the exact error', () => { const ready = createReadyGoogletag(); const displayError = new Error('exact display failure'); diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index 326e77051..501f950dd 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -68,6 +68,7 @@ function createReadyPrebid( }, renderAd: vi.fn(), requestBids: vi.fn(), + setTargetingForGPTAsync: vi.fn(), }; const stamp = options.stamp ?? createStamp(); Object.defineProperty(pbjs, '__trustedServerArtifactV1', { @@ -93,6 +94,7 @@ describe('browser Prebid adapter readiness', () => { 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'); }); @@ -104,6 +106,7 @@ describe('browser Prebid adapter readiness', () => { code: 'trustedServer', }); expect(ready.pbjs.requestBids).toHaveBeenCalledTimes(1); + expect(ready.pbjs.setTargetingForGPTAsync).toHaveBeenCalledExactlyOnceWith(['slot-a']); expect(ready.pbjs.renderAd).toHaveBeenCalledWith({}, 'bid-a'); }); @@ -619,6 +622,7 @@ describe('browser Prebid adapter readiness', () => { 'registerBidAdapter', 'renderAd', 'requestBids', + 'setTargetingForGPTAsync', ] as const) { const ready = createReadyPrebid(); Object.defineProperty(ready.pbjs, method, { value: undefined }); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 344444e8b..8d255d48d 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -29,6 +29,7 @@ import { createTestBrowserRuntimeComposition, } from '../../src/composition/browser'; import { log as localLog } from '../../src/core/log'; +import { TRACE_PANEL_ID } from '../../src/core/trace'; import type { BrowserAuctionBidV1 } from '../../src/core/types'; import { createCreativeIntegrationRegistration } from '../../src/integrations/creative/module'; import { createDataDomeIntegrationRegistration } from '../../src/integrations/datadome/module'; @@ -181,6 +182,7 @@ function synchronousPrebidAdapter() { ), renderAd: vi.fn(), requestBids: vi.fn(), + setTargetingForGpt: vi.fn(), subscribe: vi.fn( ( eventType: string, @@ -2212,7 +2214,7 @@ describe('browser composition', () => { bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, - diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + diagnostics: { version: 1, renderTraceOverlay: true, gpt: { active: false } }, }, kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, }, @@ -2305,12 +2307,18 @@ describe('browser composition', () => { 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'); @@ -2389,6 +2397,10 @@ describe('browser composition', () => { expect(contextContributor).toHaveBeenCalledTimes(4); expect(auctionFetcher).toHaveBeenCalledTimes(4); + session?.currentNavigation?.dispose(); + expect(renderTrace?.current()).toEqual({}); + expect(programmaticSlot?.hasAttribute('data-ts-rendered')).toBe(false); + composition.runtime.dispose(); expect(() => api.addAdUnits(programmatic)).toThrowError( expect.objectContaining({ name: 'AdUnitRegistrationError', code: 'slot_collision' }) 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 index 99b207791..cead76a52 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts @@ -58,7 +58,11 @@ describe('GPT startup bridge', () => { action: 'suppress', }); expect( - observer?.refresh?.({ requestedSlots: undefined, slots: Object.freeze([slot]) }) + observer?.refresh?.({ + requestedSlots: undefined, + slots: Object.freeze([slot]), + options: undefined, + }) ).toEqual({ action: 'suppress' }); observer?.destroySlots?.({ slots: Object.freeze([slot, {}]) }); expect(slots.recordPublisherDestruction).toHaveBeenCalledTimes(2); @@ -89,4 +93,66 @@ describe('GPT startup bridge', () => { 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/prebid/module.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts index 2ea424d0c..ebec85968 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -1,8 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; -import { PrebidAdmissionContractError } from '../../../src/adapters/prebid'; +import type { GoogletagAdapter } from '../../../src/adapters/googletag'; import { + PrebidAdmissionContractError, + type PrebidAdapter, + type PrebidFacade, +} from '../../../src/adapters/prebid'; +import { + createPrebidRefreshPolicy, createPrebidSelectionCoordinator, + createPrebidSyntheticRefreshRunner, createPrebidIntegrationRegistration, publishPrebidBid, type PrebidBidPublicationInput, @@ -255,6 +262,368 @@ describe('transactional Prebid integration module', () => { }); }); +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', () => { + 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({ @@ -770,6 +1139,61 @@ describe('Prebid selection coordination', () => { 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'); 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 index 0431113ea..aaae60d1f 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts @@ -7,6 +7,7 @@ import type { 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 () => { @@ -133,4 +134,126 @@ describe('Prebid startup bridge', () => { 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/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 459d72db6..c2ba251fa 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 @@ -151,7 +151,9 @@ describe('external bundle + served shim evaluated together', () => { bidderAliases: artifactManifest.bidderAliases, userIdModules: artifactManifest.userIdModules, }; - pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + 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)});` ); @@ -167,12 +169,132 @@ describe('external bundle + served shim evaluated together', () => { expect(() => pageWindow.eval(bundleCode)).not.toThrow(); expect(pageWindow.pbjs).toBe(binding); - expect(pageWindow.pbjs.requestBids).toBeUndefined(); + 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( + { + abi: artifactManifest.abi, + artifactReleaseId: artifactManifest.artifactReleaseId, + prebidVersion: artifactManifest.prebidVersion, + moduleStems: artifactManifest.moduleStems, + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, + } + )});` + ); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__exactStamp, + enumerable: false, + writable: false, + configurable: false, + }); + + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(typeof pageWindow.pbjs.requestBids).toBe('function'); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__exactStamp); + dom.window.close(); + }); + + it('accepts an exact 128-byte non-ASCII artifact name on a real stamped binding', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + const boundaryName = 'é'.repeat(64); + const boundaryStamp = { + abi: artifactManifest.abi, + artifactReleaseId: 'e'.repeat(64), + prebidVersion: artifactManifest.prebidVersion, + moduleStems: [...artifactManifest.moduleStems, boundaryName].sort(), + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, + }; + pageWindow.eval( + `window.__fakeRequestBids=function fakeRequestBids(){};window.pbjs={que:[],cmd:[]};["addAdUnits","getBidResponsesForAdUnitCode","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids","setTargetingForGPTAsync"].forEach(function(name){window.pbjs[name]=name==="requestBids"?window.__fakeRequestBids:function(){};});` + ); + 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(); + }); + + it('does not accept a UTF-8-overlong artifact name on a real stamped binding', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + const overlongName = `${'é'.repeat(64)}a`; + const malformedStamp = { + abi: artifactManifest.abi, + artifactReleaseId: 'f'.repeat(64), + prebidVersion: artifactManifest.prebidVersion, + moduleStems: [...artifactManifest.moduleStems, overlongName].sort(), + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, + }; + pageWindow.eval( + `window.__fakeRequestBids=function fakeRequestBids(){};window.pbjs={que:[],cmd:[]};["addAdUnits","getBidResponsesForAdUnitCode","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids","setTargetingForGPTAsync"].forEach(function(name){window.pbjs[name]=name==="requestBids"?window.__fakeRequestBids:function(){};});` + ); + pageWindow.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', From 548045d2ac56d47ce7a7ed7513969e8d68630ad9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:43:26 -0700 Subject: [PATCH 132/194] Prune render trace state with navigation ownership --- .../lib/src/composition/browser.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 20f82162e..f265a2f23 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -352,6 +352,7 @@ export function createTestBrowserRuntimeComposition( let gptDiagnosticsFacts: GptDiagnosticsFactBuffer | undefined; let gptDiagnosticsRuntime: GptDiagnosticsRuntime | undefined; let renderTrace: RenderTraceRuntimeOwner | undefined; + const renderTraceSlotsByNavigation = new Map>(); let acceptedBrowserBoot: AcceptedBrowserBoot | undefined; const consumeCoreObservation = (observation: DiagnosticsObservation): void => { if ( @@ -398,6 +399,12 @@ export function createTestBrowserRuntimeComposition( 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'], @@ -1020,8 +1027,14 @@ export function createTestBrowserRuntimeComposition( testlight: testlightRuntime, ...services, }), - onNavigationDispose: (navigationGeneration) => - artifacts.disposeNavigation(navigationGeneration), + onNavigationDispose: (navigationGeneration) => { + artifacts.disposeNavigation(navigationGeneration); + for (const registeredSlotId of + renderTraceSlotsByNavigation.get(navigationGeneration) ?? []) { + preparedRenderTrace.prune(registeredSlotId); + } + renderTraceSlotsByNavigation.delete(navigationGeneration); + }, }); context.onDispose(() => { batchCoordinator.dispose(); @@ -1043,6 +1056,7 @@ export function createTestBrowserRuntimeComposition( acceptedBrowserBoot = undefined; creativeBoot = undefined; diagnosticsBoot = undefined; + renderTraceSlotsByNavigation.clear(); } }); const navigation = session.startInitialNavigation(initialProjection); From 8d3bb4d686ffd614f4ee88775ebc4f1db2cfba9b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:44:49 -0700 Subject: [PATCH 133/194] Satisfy strict consent timer initialization --- .../lib/src/integrations/osano/consent_mirror.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts b/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts index 7592bfadc..cb5b40516 100644 --- a/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts +++ b/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts @@ -207,7 +207,7 @@ function readUspSignal(win: OsanoWindow): Promise { if (typeof win.__uspapi !== 'function') return Promise.resolve(unavailableResult()); return new Promise((resolve) => { - let timer: number | undefined; + let timer: number | undefined = undefined; let cancelPending = (): void => undefined; const done = finishOnce((result: SignalResult) => { if (timer !== undefined) window.clearTimeout(timer); @@ -248,7 +248,7 @@ function readGppSignal(win: OsanoWindow): Promise { if (typeof win.__gpp !== 'function') return Promise.resolve(unavailableResult()); return new Promise((resolve) => { - let timer: number | undefined; + let timer: number | undefined = undefined; let cancelPending = (): void => undefined; const done = finishOnce((result: SignalResult) => { if (timer !== undefined) window.clearTimeout(timer); @@ -313,7 +313,7 @@ function readTcfSignal(win: OsanoWindow): Promise { if (typeof win.__tcfapi !== 'function') return Promise.resolve(unavailableResult()); return new Promise((resolve) => { - let timer: number | undefined; + let timer: number | undefined = undefined; let cancelPending = (): void => undefined; const done = finishOnce((result: SignalResult) => { if (timer !== undefined) window.clearTimeout(timer); From 4dbdd9c2d17e3e3f059cb614672cf7aad1b2b1d7 Mon Sep 17 00:00:00 2001 From: AG <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:31:21 -0700 Subject: [PATCH 134/194] Use string-form Cargo aliases so nested worktrees do not break them (#1004) Cargo discovers .cargo/config.toml in every ancestor directory, so a worktree nested inside the repo (e.g. .claude/worktrees/*) loads both the worktree's copy and the parent checkout's copy. Array-valued config keys merge by concatenation, expanding every alias to doubled tokens ("check ... check ...") and failing with: unexpected argument 'check'. String values are overridden by the deeper config instead of merged, and Cargo splits string aliases on whitespace, so behavior is otherwise identical. --- .cargo/config.toml | 56 +++++++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 93ec9484b..1302091e0 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -9,10 +9,16 @@ # default-members = [fastly] — required so Viceroy can locate the binary via `cargo run --bin`. # The aliases below are grouped by adapter; each targets its adapter with the # correct toolchain (build / check / test / clippy). +# +# Aliases use string form, not array form: in a worktree nested inside the +# repo (e.g. .claude/worktrees/*) Cargo discovers this file twice — the +# worktree's copy and the parent checkout's — and merges array values by +# concatenation, doubling every token ("check ... check ..."). +# String values are overridden by the deeper config instead of merged. [alias] # Generic: native test with an explicit host target. -test_details = ["test", "--target", "aarch64-apple-darwin"] +test_details = "test --target aarch64-apple-darwin" # --- Fastly adapter (wasm32-wasip1, run via Viceroy) --- # Whitelist the wasm-buildable crates (the Fastly adapter + the shared crates it @@ -20,43 +26,43 @@ test_details = ["test", "--target", "aarch64-apple-darwin"] # native crate needs no change here. Axum (native), Cloudflare # (wasm32-unknown-unknown), Spin, the CLI (native), and integration-tests # (native) are simply not listed. -build-fastly = ["build", "-p", "trusted-server-core", "-p", "trusted-server-adapter-fastly", "-p", "trusted-server-js", "-p", "trusted-server-openrtb", "--target", "wasm32-wasip1"] -check-fastly = ["check", "-p", "trusted-server-core", "-p", "trusted-server-adapter-fastly", "-p", "trusted-server-js", "-p", "trusted-server-openrtb", "--target", "wasm32-wasip1"] -clippy-fastly = ["clippy", "-p", "trusted-server-core", "-p", "trusted-server-adapter-fastly", "-p", "trusted-server-js", "-p", "trusted-server-openrtb", "--all-targets", "--all-features", "--target", "wasm32-wasip1", "--", "-D", "warnings"] -test-fastly = ["test", "-p", "trusted-server-core", "-p", "trusted-server-adapter-fastly", "-p", "trusted-server-js", "-p", "trusted-server-openrtb", "--target", "wasm32-wasip1"] +build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" +test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" # --- Axum adapter (native dev server) --- -build-axum = ["build", "-p", "trusted-server-adapter-axum"] -check-axum = ["check", "-p", "trusted-server-adapter-axum"] -clippy-axum = ["clippy", "-p", "trusted-server-adapter-axum", "--all-targets", "--all-features", "--", "-D", "warnings"] -test-axum = ["test", "-p", "trusted-server-adapter-axum"] +build-axum = "build -p trusted-server-adapter-axum" +check-axum = "check -p trusted-server-adapter-axum" +clippy-axum = "clippy -p trusted-server-adapter-axum --all-targets --all-features -- -D warnings" +test-axum = "test -p trusted-server-adapter-axum" # --- Cloudflare adapter (native host + wasm32-unknown-unknown) --- # Build/check target the WASM runtime (requires the `cloudflare` feature); # tests run on the native host; clippy covers both native test code and the # production WASM feature. -build-cloudflare = ["build", "-p", "trusted-server-adapter-cloudflare", "--target", "wasm32-unknown-unknown", "--features", "cloudflare"] -check-cloudflare = ["check", "-p", "trusted-server-adapter-cloudflare", "--target", "wasm32-unknown-unknown", "--features", "cloudflare"] +build-cloudflare = "build -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare" +check-cloudflare = "check -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare" # No --all-features: the `cloudflare` feature has a compile_error! guard on # non-wasm32 targets. -clippy-cloudflare = ["clippy", "-p", "trusted-server-adapter-cloudflare", "--all-targets", "--", "-D", "warnings"] -clippy-cloudflare-wasm = ["clippy", "-p", "trusted-server-adapter-cloudflare", "--target", "wasm32-unknown-unknown", "--features", "cloudflare", "--lib", "--", "-D", "warnings"] -test-cloudflare = ["test", "-p", "trusted-server-adapter-cloudflare"] +clippy-cloudflare = "clippy -p trusted-server-adapter-cloudflare --all-targets -- -D warnings" +clippy-cloudflare-wasm = "clippy -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare --lib -- -D warnings" +test-cloudflare = "test -p trusted-server-adapter-cloudflare" # --- Spin adapter (native host tests + wasm32-wasip1 target) --- -check-spin = ["check", "-p", "trusted-server-adapter-spin", "--target", "wasm32-wasip1", "--features", "spin"] -clippy-spin-native = ["clippy", "-p", "trusted-server-adapter-spin", "--all-targets", "--", "-D", "warnings"] -clippy-spin-wasm = ["clippy", "-p", "trusted-server-adapter-spin", "--target", "wasm32-wasip1", "--features", "spin", "--lib", "--", "-D", "warnings"] -test-spin = ["test", "-p", "trusted-server-adapter-spin"] +check-spin = "check -p trusted-server-adapter-spin --target wasm32-wasip1 --features spin" +clippy-spin-native = "clippy -p trusted-server-adapter-spin --all-targets -- -D warnings" +clippy-spin-wasm = "clippy -p trusted-server-adapter-spin --target wasm32-wasip1 --features spin --lib -- -D warnings" +test-spin = "test -p trusted-server-adapter-spin" # --- ts operator CLI (native host; install uses the current host platform) --- -install-cli = ["install", "--path", "crates/trusted-server-cli", "--bin", "ts", "--locked", "--force"] -build_cli_linux = ["build", "--package", "trusted-server-cli", "--target", "x86_64-unknown-linux-gnu"] -build_cli_macos = ["build", "--package", "trusted-server-cli", "--target", "aarch64-apple-darwin"] -run_cli_linux = ["run", "--package", "trusted-server-cli", "--target", "x86_64-unknown-linux-gnu", "--"] -run_cli_macos = ["run", "--package", "trusted-server-cli", "--target", "aarch64-apple-darwin", "--"] -test_cli_linux = ["test", "--package", "trusted-server-cli", "--target", "x86_64-unknown-linux-gnu"] -test_cli_macos = ["test", "--package", "trusted-server-cli", "--target", "aarch64-apple-darwin"] +install-cli = "install --path crates/trusted-server-cli --bin ts --locked --force" +build_cli_linux = "build --package trusted-server-cli --target x86_64-unknown-linux-gnu" +build_cli_macos = "build --package trusted-server-cli --target aarch64-apple-darwin" +run_cli_linux = "run --package trusted-server-cli --target x86_64-unknown-linux-gnu --" +run_cli_macos = "run --package trusted-server-cli --target aarch64-apple-darwin --" +test_cli_linux = "test --package trusted-server-cli --target x86_64-unknown-linux-gnu" +test_cli_macos = "test --package trusted-server-cli --target aarch64-apple-darwin" # When a wasm binary IS built, run it under Viceroy. [target.'cfg(all(target_arch = "wasm32"))'] From 38897a3e85693646fedda017bb177f83264b7b81 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:50:14 -0700 Subject: [PATCH 135/194] Compose the Prebid refresh policy --- .../lib/src/composition/browser.ts | 104 ++++++++++++ .../lib/src/integrations/prebid/module.ts | 122 ++++++++++++++ .../lib/test/composition/browser.test.ts | 157 +++++++++++++++++- 3 files changed, 380 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index f265a2f23..9f5e4420d 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -71,7 +71,10 @@ import { type GptDiagnosticsRuntime, } from '../integrations/gpt_diagnostics'; import { + createPrebidRefreshPolicy, createPrebidSelectionCoordinator, + createPrebidSyntheticRefreshRunner, + preparePrebidRegisteredRefreshAuction, publishPrebidBid, type PrebidSelectionCoordinator, } from '../integrations/prebid/module'; @@ -244,6 +247,85 @@ function projectionSlots(projection: object): readonly string[] { return Object.freeze(accepted.auction.results.map(({ slot }) => slot)); } +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, @@ -463,7 +545,22 @@ export function createTestBrowserRuntimeComposition( }); let runtimeSession: RuntimeSession | undefined; 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(); @@ -530,6 +627,13 @@ export function createTestBrowserRuntimeComposition( 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) => { diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 9efe63408..25e78f19d 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -1,5 +1,6 @@ import { isRendererReservationIdV1, + ownDataArray, ownDataObject, validBoundedString, validDimension, @@ -35,6 +36,7 @@ import type { import type { ReservationService } from '../../services/reservations'; export const PREBID_INTEGRATION_ID = 'prebid' as const; +const TRUSTED_SERVER_PREBID_BIDDER = 'trustedServer'; export type { PreparedTrustedBidV1 } from '../../adapters/prebid'; const MAX_CONFIG_DEPTH = 16; @@ -192,6 +194,12 @@ export interface PrebidRefreshAuctionPreparation { readonly adUnits: readonly object[]; } +export interface PrebidRegisteredRefreshAuctionOptions { + readonly clientSideBidders: readonly string[]; + readonly resolveAdUnit: (slot: object) => unknown; + readonly slots: readonly object[]; +} + export interface PrebidRefreshAuctionOperation { readonly completion: Promise; readonly dispose: () => void; @@ -272,6 +280,120 @@ function validRefreshAuctionPreparation( } } +function defineDataProperty(target: Record, key: string, value: unknown): void { + Object.defineProperty(target, key, { + configurable: false, + enumerable: true, + value, + writable: false, + }); +} + +/** + * Rebuild synthetic Prebid units from detached runtime-owned registrations. + * + * The composition root resolves physical GPT identities to registered units; + * this integration-owned boundary performs all bidder routing without reading + * mutable `pbjs.adUnits` publisher state. + */ +export function preparePrebidRegisteredRefreshAuction( + options: PrebidRegisteredRefreshAuctionOptions +): PrebidRefreshAuctionPreparation | undefined { + try { + if ( + options.slots.length === 0 || + options.slots.length > MAX_PREBID_REFRESH_AD_UNITS || + !Object.isFrozen(options.slots) + ) { + return undefined; + } + const clientSideBidders = new Set(); + for (let index = 0; index < options.clientSideBidders.length; index += 1) { + const bidder = options.clientSideBidders[index]; + if (!validBoundedString(bidder, 64)) return undefined; + clientSideBidders.add(bidder); + } + + const adUnitCodes: string[] = []; + const adUnits: object[] = []; + const seenCodes = new Set(); + for (let slotIndex = 0; slotIndex < options.slots.length; slotIndex += 1) { + const slot = options.slots[slotIndex]; + if (!slot) return undefined; + const source = ownDataObject(options.resolveAdUnit(slot)); + if (!source || !validBoundedString(source.code, 128) || seenCodes.has(source.code)) { + return undefined; + } + const mediaTypes = ownDataObject(source.mediaTypes); + if (!mediaTypes || !Object.isFrozen(source.mediaTypes)) return undefined; + const rawBids = + source.bids === undefined + ? [] + : ownDataArray(source.bids, MAX_CONFIG_MEMBERS); + if (!rawBids || (source.bids !== undefined && !Object.isFrozen(source.bids))) { + return undefined; + } + + const bidderParams: Record = {}; + const trustedParams: Record = {}; + const clientBids: object[] = []; + let foundTrustedBid = false; + for (let bidIndex = 0; bidIndex < rawBids.length; bidIndex += 1) { + const bid = ownDataObject(rawBids[bidIndex]); + if (!bid || !validBoundedString(bid.bidder, 64)) return undefined; + const params = bid.params === undefined ? Object.freeze({}) : bid.params; + if (!ownDataObject(params) || !Object.isFrozen(params)) return undefined; + if (bid.bidder === TRUSTED_SERVER_PREBID_BIDDER) { + if (foundTrustedBid) return undefined; + foundTrustedBid = true; + const existingParams = ownDataObject(params); + if (!existingParams) return undefined; + for (const [key, value] of Object.entries(existingParams)) { + if (key !== 'bidderParams') defineDataProperty(trustedParams, key, value); + } + const folded = existingParams['bidderParams']; + if (folded !== undefined) { + const foldedRecord = ownDataObject(folded); + if (!foldedRecord || !Object.isFrozen(folded)) return undefined; + for (const [bidder, bidderValue] of Object.entries(foldedRecord)) { + if (!validBoundedString(bidder, 64) || !ownDataObject(bidderValue)) return undefined; + defineDataProperty(bidderParams, bidder, bidderValue); + } + } + continue; + } + if (clientSideBidders.has(bid.bidder)) { + clientBids.push(Object.freeze({ bidder: bid.bidder, params })); + continue; + } + defineDataProperty(bidderParams, bid.bidder, params); + } + + defineDataProperty(trustedParams, 'bidderParams', Object.freeze(bidderParams)); + const synthetic = Object.freeze({ + code: source.code, + mediaTypes: source.mediaTypes, + bids: Object.freeze([ + Object.freeze({ + bidder: TRUSTED_SERVER_PREBID_BIDDER, + params: Object.freeze(trustedParams), + }), + ...clientBids, + ]), + }); + seenCodes.add(source.code); + adUnitCodes.push(source.code); + adUnits.push(synthetic); + } + return Object.freeze({ + adUnitCodes: Object.freeze(adUnitCodes), + adUnits: Object.freeze(adUnits), + }); + } catch { + return undefined; + } +} + /** Run one synthetic refresh auction through the exact current Prebid adapter binding. */ export function createPrebidSyntheticRefreshRunner( options: PrebidSyntheticRefreshRunnerOptions diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 8d255d48d..dbc3a222c 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -7,6 +7,8 @@ import { type GoogletagBindingStatus, type GoogletagDiagnosticsObserver, type GoogletagFacade, + type GoogletagPublisherCallObserver, + type GoogletagPublisherRefreshCall, } from '../../src/adapters/googletag'; import { createBrowserMessagingAdapter, @@ -75,7 +77,12 @@ function synchronousGptAdapter() { const bindingToken = Object.freeze({}); const refresh = vi.fn(); let diagnosticsObserver: GoogletagDiagnosticsObserver | undefined; + let publisherObserver: GoogletagPublisherCallObserver | undefined; 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); @@ -115,7 +122,12 @@ function synchronousGptAdapter() { if (diagnosticsObserver === observer) diagnosticsObserver = undefined; }; }, - observePublisherCalls: () => vi.fn(), + observePublisherCalls: (observer: GoogletagPublisherCallObserver) => { + publisherObserver = observer; + return () => { + if (publisherObserver === observer) publisherObserver = undefined; + }; + }, run: (command: (gpt: Readonly) => Value) => { let result: Promise; try { @@ -148,6 +160,11 @@ function synchronousGptAdapter() { .filter(([, registered]) => registered.size > 0) .map(([eventType, registered]) => Object.freeze([eventType, registered.size] as const)) ), + publisherRefresh: (call: Readonly) => { + const observer = publisherObserver; + if (!observer?.refresh) throw new Error('Publisher observer is unavailable'); + return observer.refresh(call); + }, refresh, }; } @@ -167,6 +184,8 @@ function synchronousPrebidAdapter() { admitted = prepared; return 'admitted' as const; }); + const requestBids = vi.fn(); + const setTargetingForGpt = vi.fn(); const facade = Object.freeze({ addAdUnits: vi.fn(), highestBids: vi.fn(() => Object.freeze([])), @@ -181,8 +200,8 @@ function synchronousPrebidAdapter() { } ), renderAd: vi.fn(), - requestBids: vi.fn(), - setTargetingForGpt: vi.fn(), + requestBids, + setTargetingForGpt, subscribe: vi.fn( ( eventType: string, @@ -229,6 +248,8 @@ function synchronousPrebidAdapter() { Object.freeze({ highestBids: () => highest }) ); }, + requestBids, + setTargetingForGpt, }; } @@ -1000,6 +1021,136 @@ describe('browser composition', () => { 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: { + version: 1, + releaseId, + integrations: [ + { id: 'gpt', required: true }, + { id: 'prebid', required: true }, + ], + }, + knownIntegrationIds: Object.freeze(['gpt', 'prebid']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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(createPrebidIntegrationRegistration(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); From fc27df98a47ac2514545f40166cedd61c8bd2179 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:51:59 -0700 Subject: [PATCH 136/194] Harden synthetic Prebid refresh routing --- .../lib/src/integrations/prebid/module.ts | 10 ++- .../test/integrations/prebid/module.test.ts | 70 +++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 25e78f19d..2220d39e0 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -334,7 +334,7 @@ export function preparePrebidRegisteredRefreshAuction( return undefined; } - const bidderParams: Record = {}; + const bidderParamEntries = new Map(); const trustedParams: Record = {}; const clientBids: object[] = []; let foundTrustedBid = false; @@ -357,7 +357,7 @@ export function preparePrebidRegisteredRefreshAuction( if (!foldedRecord || !Object.isFrozen(folded)) return undefined; for (const [bidder, bidderValue] of Object.entries(foldedRecord)) { if (!validBoundedString(bidder, 64) || !ownDataObject(bidderValue)) return undefined; - defineDataProperty(bidderParams, bidder, bidderValue); + if (!clientSideBidders.has(bidder)) bidderParamEntries.set(bidder, bidderValue); } } continue; @@ -366,9 +366,13 @@ export function preparePrebidRegisteredRefreshAuction( clientBids.push(Object.freeze({ bidder: bid.bidder, params })); continue; } - defineDataProperty(bidderParams, bid.bidder, params); + bidderParamEntries.set(bid.bidder, params); } + const bidderParams: Record = {}; + for (const [bidder, params] of bidderParamEntries) { + defineDataProperty(bidderParams, bidder, params); + } defineDataProperty(trustedParams, 'bidderParams', Object.freeze(bidderParams)); const synthetic = Object.freeze({ code: source.code, 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 index ebec85968..611e5bbd8 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -11,6 +11,7 @@ import { createPrebidSelectionCoordinator, createPrebidSyntheticRefreshRunner, createPrebidIntegrationRegistration, + preparePrebidRegisteredRefreshAuction, publishPrebidBid, type PrebidBidPublicationInput, type PreparedTrustedBidV1, @@ -485,6 +486,75 @@ describe('RCJ-PREBID-04 prospective refresh policy', () => { }); 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('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: () => From 6e0c068b0e748b58201c29e047fe2c8a2de4bc85 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:54:00 -0700 Subject: [PATCH 137/194] Test synthetic Prebid refresh boundaries --- .../test/integrations/prebid/module.test.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) 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 index 611e5bbd8..c7ac910dc 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -543,6 +543,157 @@ describe('RCJ-PREBID-04 adapter-backed synthetic refresh runner', () => { 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' }); From be5a7a2d30974212485f2ed537ad0dc6e5759c13 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:56:05 -0700 Subject: [PATCH 138/194] Format Prebid refresh composition --- .../trusted-server-js/lib/src/composition/browser.ts | 11 +++-------- .../lib/src/integrations/prebid/module.ts | 4 +--- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 9f5e4420d..3cdc9d892 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -302,11 +302,7 @@ function composedPrebidRefreshAuction( const record = records[recordIndex]; if ( !record || - !slots.isBoundGptSlot( - navigation.generation, - record.registeredSlotId, - physicalSlot - ) + !slots.isBoundGptSlot(navigation.generation, record.registeredSlotId, physicalSlot) ) { continue; } @@ -1030,7 +1026,6 @@ export function createTestBrowserRuntimeComposition( cachePolicy, fetcher: (input, init) => fetchCache(input, init), onResolved, - publisherOrigin, }); } catch { return false; @@ -1133,8 +1128,8 @@ export function createTestBrowserRuntimeComposition( }), onNavigationDispose: (navigationGeneration) => { artifacts.disposeNavigation(navigationGeneration); - for (const registeredSlotId of - renderTraceSlotsByNavigation.get(navigationGeneration) ?? []) { + for (const registeredSlotId of renderTraceSlotsByNavigation.get(navigationGeneration) ?? + []) { preparedRenderTrace.prune(registeredSlotId); } renderTraceSlotsByNavigation.delete(navigationGeneration); diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 2220d39e0..196ac4566 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -327,9 +327,7 @@ export function preparePrebidRegisteredRefreshAuction( const mediaTypes = ownDataObject(source.mediaTypes); if (!mediaTypes || !Object.isFrozen(source.mediaTypes)) return undefined; const rawBids = - source.bids === undefined - ? [] - : ownDataArray(source.bids, MAX_CONFIG_MEMBERS); + source.bids === undefined ? [] : ownDataArray(source.bids, MAX_CONFIG_MEMBERS); if (!rawBids || (source.bids !== undefined && !Object.isFrozen(source.bids))) { return undefined; } From 753933ea3c181ba1a35eacf62f674f6b986c9eb1 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:56:48 -0700 Subject: [PATCH 139/194] Require CORS for cache creative fetches --- .../lib/src/services/render.ts | 17 ++-------- .../lib/test/services/render.test.ts | 33 ++++++++----------- 2 files changed, 16 insertions(+), 34 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index 5d9c059c8..06ce5c5d4 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -733,7 +733,6 @@ export interface CacheAdmResolutionOptions { readonly cachePolicy: Readonly; readonly fetcher: CacheFetcher; readonly onResolved: (source: Readonly) => boolean; - readonly publisherOrigin: string; } export interface DirectCacheAttemptOptions extends DirectAdmAttemptOptions { @@ -2508,13 +2507,11 @@ export function resolveCacheAdmAttempt(options: CacheAdmResolutionOptions): bool let cachePolicy: CacheAdmResolutionOptions['cachePolicy']; let fetchCache: CacheAdmResolutionOptions['fetcher']; let onResolved: CacheAdmResolutionOptions['onResolved']; - let publisherOrigin: string; try { attempt = options.attempt; cachePolicy = options.cachePolicy; fetchCache = options.fetcher; onResolved = options.onResolved; - publisherOrigin = options.publisherOrigin; } catch { return false; } @@ -2531,15 +2528,7 @@ export function resolveCacheAdmAttempt(options: CacheAdmResolutionOptions): bool const source = readDirectCacheSource(attempt.renderSource, cachePolicy); const winnerContext = attempt.winnerContext; const selectedCpm = readSelectedCpm(winnerContext); - let cacheOrigin: string | undefined; - try { - cacheOrigin = source - ? urlPart(Reflect.construct(urlIntrinsic, [source.fetchUrl]) as URL, 'origin') - : undefined; - } catch { - cacheOrigin = undefined; - } - if (!source || selectedCpm === undefined || cacheOrigin === undefined) { + if (!source || selectedCpm === undefined) { attempt.fail('descriptor_invalid'); return false; } @@ -2674,8 +2663,7 @@ export function resolveCacheAdmAttempt(options: CacheAdmResolutionOptions): bool failCache('cache_network_error'); return; } - const expectedResponseType = cacheOrigin === publisherOrigin ? 'basic' : 'cors'; - if (responseType !== expectedResponseType) { + if (responseType !== 'cors') { failCache('cache_network_error'); return; } @@ -2784,7 +2772,6 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo fetcher, onResolved: (source) => renderAdmAttempt({ attempt, container, prepareIframe, publisherOrigin }, source), - publisherOrigin, }); } diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index b08aee273..ff9893538 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -2157,7 +2157,7 @@ describe('direct cache attempt rendering', () => { }); it.each(['basic', 'default', undefined] as const)( - 'rejects a cross-origin response with non-CORS type %s', + 'rejects every response with non-CORS type %s', async (responseType) => { document.body.innerHTML = '
'; const render = attempt(); @@ -2188,7 +2188,7 @@ describe('direct cache attempt rendering', () => { } ); - it('accepts a basic response only when the cache and publisher origins match', async () => { + 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
' })); @@ -2201,16 +2201,18 @@ describe('direct cache attempt rendering', () => { cachePolicy: CACHE_POLICY, fetcher: async () => response, onResolved, - publisherOrigin: new URL(CACHE_SOURCE.fetchUrl).origin, }) ).toBe(true); - await vi.waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); - expect(onResolved.mock.calls[0]?.[0]).toMatchObject({ adm: '
same origin
' }); - expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'rendering_direct' }); - expect(render.cancel('caller_aborted')).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }) + ); + expect(onResolved).not.toHaveBeenCalled(); }); - it('rejects a CORS response when the cache and publisher origins match', async () => { + 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); @@ -2221,16 +2223,12 @@ describe('direct cache attempt rendering', () => { cachePolicy: CACHE_POLICY, fetcher: async () => corsResponse(JSON.stringify({ adm: '
wrong type
' })), onResolved, - publisherOrigin: new URL(CACHE_SOURCE.fetchUrl).origin, }) ).toBe(true); - await vi.waitFor(() => - expect(render.snapshot().outcome).toEqual({ - outcome: 'failed', - reason: 'cache_network_error', - }) - ); - expect(onResolved).not.toHaveBeenCalled(); + 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', () => { @@ -2450,7 +2448,6 @@ describe('direct cache attempt rendering', () => { cachePolicy: CACHE_POLICY, fetcher: fetchCache, onResolved, - publisherOrigin: window.location.origin, }) ).toBe(true); expect(render.snapshot()).toMatchObject({ @@ -2508,7 +2505,6 @@ describe('direct cache attempt rendering', () => { cachePolicy: CACHE_POLICY, fetcher, onResolved, - publisherOrigin: window.location.origin, }) ).toBe(true); await vi.advanceTimersByTimeAsync(4_999); @@ -2606,7 +2602,6 @@ describe('direct cache attempt rendering', () => { cachePolicy: CACHE_POLICY, fetcher, onResolved, - publisherOrigin: window.location.origin, }) ).toBe(true); await vi.waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); From 1c2a4549bfb4338d54196124b89e043adfdf5bec Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:00:39 -0700 Subject: [PATCH 140/194] Enforce Axum APS proxy transport deadlines --- .../src/platform.rs | 104 ++++++++++++++++-- scripts/integration-tests-aps-runner-proxy.sh | 12 +- 2 files changed, 108 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index c823fbc41..a44ceb147 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -482,9 +482,12 @@ impl AxumPlatformHttpClient { } tokio::time::timeout(policy.total_timeout, async move { - let mut response = builder - .send() + 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(), @@ -509,11 +512,15 @@ impl AxumPlatformHttpClient { } let mut body = Vec::new(); - while let Some(chunk) = response - .chunk() - .await - .change_context(PlatformError::HttpClient)? - { + 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") })?; @@ -1048,6 +1055,89 @@ mod tests { 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/scripts/integration-tests-aps-runner-proxy.sh b/scripts/integration-tests-aps-runner-proxy.sh index ac653aa95..dd3144214 100755 --- a/scripts/integration-tests-aps-runner-proxy.sh +++ b/scripts/integration-tests-aps-runner-proxy.sh @@ -178,7 +178,17 @@ else fi CARGO_TEST_PID="$!" -CHILD_PGID="$(ps -o pgid= -p "$CARGO_TEST_PID" 2>/dev/null | tr -d '[:space:]' || true)" +CHILD_PGID="" +# The background child can be observed between fork and `setsid(2)`, especially +# when `setsid` is provided by a shim on BSD/macOS. Give it a bounded moment to +# enter its dedicated process group before enforcing the cleanup invariant. +for ((attempt = 0; attempt < 50; attempt += 1)); do + CHILD_PGID="$(ps -o pgid= -p "$CARGO_TEST_PID" 2>/dev/null | tr -d '[:space:]' || true)" + if [[ "$CHILD_PGID" =~ ^[1-9][0-9]*$ ]] && [ "$CHILD_PGID" != "$SHELL_PGID" ]; then + break + fi + sleep 0.01 +done if [[ "$CHILD_PGID" =~ ^[1-9][0-9]*$ ]] && [ "$CHILD_PGID" != "$SHELL_PGID" ]; then CARGO_TEST_PGID="$CHILD_PGID" else From f1a8ad8d28db8dd8c9606e215fd4938981047eb8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:07:08 -0700 Subject: [PATCH 141/194] Test APS deadline at the transport boundary --- .../tests/aps_runner_proxy.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs b/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs index feb2e5823..c038dc03a 100644 --- a/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs +++ b/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs @@ -22,6 +22,12 @@ const SUCCESS_HEADERS: [&str; 5] = [ "x-content-type-options", ]; +// The platform policy owns an exact five-second dispatch-through-final-byte +// deadline. This black-box clock additionally observes downstream request +// dispatch, local error serialization, and response delivery, so retain a +// bounded allowance for work outside the transport window. +const DOWNSTREAM_DEADLINE_OBSERVATION_ALLOWANCE: Duration = Duration::from_millis(250); + struct CorpusCase { name: &'static str, upstream: FictionalResponse, @@ -53,7 +59,9 @@ impl CorpusCase { fn deadline(name: &'static str, upstream: FictionalResponse) -> Self { Self { - maximum_elapsed: Some(Duration::from_secs(5)), + maximum_elapsed: Some( + Duration::from_secs(5) + DOWNSTREAM_DEADLINE_OBSERVATION_ALLOWANCE, + ), ..Self::failure(name, upstream) } } @@ -404,7 +412,8 @@ fn actual_adapter_proxy_corpus() { let client = Client::builder() .redirect(reqwest::redirect::Policy::none()) // This is only a downstream dead-test guard. Deadline corpus cases - // retain their independent, stricter five-second elapsed assertion. + // 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)) From e9358f5f55278cc898fd49913ddb87b3311c0077 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:10:03 -0700 Subject: [PATCH 142/194] Settle failed Prebid publications --- .../lib/src/composition/browser.ts | 14 +- .../lib/src/integrations/prebid/module.ts | 57 ++++++ .../lib/test/composition/browser.test.ts | 166 +++++++++++++++++- 3 files changed, 233 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 3cdc9d892..7d426823a 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -591,7 +591,7 @@ export function createTestBrowserRuntimeComposition( if (bids.length !== 1) continue; const bid = bids[0]; if (!bid) continue; - publishPrebidBid({ + const publication = publishPrebidBid({ admitTrustedBid: (preparedBid) => composition.adapters.prebid.admitTrustedBid(preparedBid), auctionId: auction.auctionId, @@ -608,6 +608,18 @@ export function createTestBrowserRuntimeComposition( 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. diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 196ac4566..bf35e7773 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -748,6 +748,11 @@ export type PrebidBidPublicationResult = | Readonly<{ ok: true; bid: Readonly }> | Readonly<{ ok: false; reason: PrebidBidPublicationFailureReason }>; +export type PrebidPublicationLifecycleFailureReason = Extract< + PrebidBidPublicationFailureReason, + 'prebid_admission_failed' | 'prebid_contract_violation' +>; + type PrebidPublicationNavigation = NavigationSession; export interface PrebidBidPublicationInput { @@ -969,6 +974,12 @@ export interface PrebidSelectionCoordinator { navigation: NavigationSession ) => boolean; readonly auctionEnded: (event: unknown, prebid: Readonly) => void; + readonly settlePublicationFailure: ( + navigation: NavigationSession, + auctionId: string, + adUnitCode: string, + reason: PrebidPublicationLifecycleFailureReason + ) => boolean; readonly abort: (navigation: NavigationSession, auctionId: string) => void; readonly dispose: () => void; } @@ -1182,6 +1193,51 @@ export function createPrebidSelectionCoordinator( } }; + const settlePublicationFailure = ( + navigation: NavigationSession, + auctionId: string, + adUnitCode: string, + reason: PrebidPublicationLifecycleFailureReason + ): boolean => { + let ephemeralBatch: AuctionBatchScope | undefined; + try { + if ( + disposed || + !navigation.isCurrent() || + !validBoundedString(auctionId, 128) || + !validBoundedString(adUnitCode, 256) + ) { + return false; + } + const tracked = findAuction(navigation, auctionId); + const batch = tracked?.batch ?? navigation.createAuctionBatch(`prebid:${auctionId}`); + if (!batch) return false; + if (!tracked) ephemeralBatch = batch; + const owner = batch.createRenderAttempt(adUnitCode); + if (!owner.ok) return false; + let created: RenderAttemptCreationResult; + try { + created = options.createAttempt(owner.value); + } catch { + owner.value.dispose(); + return false; + } + if (!created.ok) { + owner.value.dispose(); + return false; + } + return created.value.fail(reason); + } catch { + return false; + } finally { + try { + ephemeralBatch?.dispose(); + } catch { + // The failed attempt is already terminal; navigation remains the final owner. + } + } + }; + const auctionEnded = (event: unknown, prebid: Readonly): void => { if (disposed) return; const record = ownDataObject(event); @@ -1302,6 +1358,7 @@ export function createPrebidSelectionCoordinator( return Object.freeze({ track, auctionEnded, + settlePublicationFailure, abort, dispose: (): void => { if (disposed) return; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index dbc3a222c..176c3136c 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -18,6 +18,7 @@ import { } from '../../src/adapters/messaging'; import { createNoopPrebidAdapter, + PrebidAdmissionContractError, type PrebidAdapter, type PrebidBindingStatus, type PrebidEventFacade, @@ -175,14 +176,18 @@ function fakePrebidAdapter( return Object.freeze({ ...createNoopPrebidAdapter(), bindingStatus }); } -function synchronousPrebidAdapter() { +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) => { - admitted = prepared; - return 'admitted' as const; + const result = admission(prepared); + if (result === 'admitted') admitted = prepared; + return result; }); const requestBids = vi.fn(); const setTargetingForGpt = vi.fn(); @@ -1491,6 +1496,161 @@ describe('browser composition', () => { } }); + 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 observations: Readonly>[] = []; + 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, + }), + ]), + }), + bids: Object.freeze([bid]), + }); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [ + { id: 'prebid', required: true }, + { id: 'lifecycle_probe', required: true }, + ], + }, + 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(createPrebidIntegrationRegistration(releaseId)) + ).toBe(true); + expect( + composition.runtime.registerIntegration({ + id: 'lifecycle_probe', + release: releaseId, + prepare: ({ + interfaces, + onDispose, + }: { + interfaces: Readonly>; + onDispose(callback: () => void): void; + }) => { + const diagnostics = interfaces['diagnostics'] as { + subscribe( + id: string, + listener: (observation: Readonly>) => void + ): (() => void) | undefined; + }; + const release = diagnostics.subscribe('lifecycle_probe', (observation) => + observations.push(observation) + ); + if (!release) throw new Error('Expected the lifecycle diagnostics subscription'); + onDispose(release); + return { 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, + }); + await vi.waitFor(() => + expect(observations).toContainEqual( + expect.objectContaining({ + kind: 'render_attempt', + slotId: bid.slot, + state: 'failed', + outcome: { outcome: 'failed', 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' }); From 517c78f839a09162ef68ba5c2f96e23702911bfa Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:13:08 -0700 Subject: [PATCH 143/194] Test hostile Prebid failure settlement --- .../test/integrations/prebid/module.test.ts | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) 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 index c7ac910dc..fbb84207c 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -1089,6 +1089,7 @@ describe('Prebid selection coordination', () => { activateResult?: boolean; synchronousTimer?: boolean; throwCreateAttempt?: boolean; + throwFail?: boolean; throwPromotion?: boolean; }> = {} ) { @@ -1133,7 +1134,20 @@ describe('Prebid selection coordination', () => { : undefined, reservations, }); - if (result.ok) attempts.push(result.value); + 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: { @@ -1221,6 +1235,28 @@ describe('Prebid selection coordination', () => { }; } + 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'); From faec1fcc0fc3bf89c42aa83c5c9a078e3fe06134 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:14:50 -0700 Subject: [PATCH 144/194] Route GPT diagnostics through the kernel bus --- .../lib/src/composition/browser.ts | 26 +++++- .../lib/test/composition/browser.test.ts | 88 +++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 7d426823a..daa503496 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -2,6 +2,7 @@ import { createBrowserGoogletagAdapter, createNoopGoogletagAdapter, type GoogletagAdapter, + type GoogletagDiagnosticsFact, type GoogletagGlobalTarget, } from '../adapters/googletag'; import { @@ -433,6 +434,23 @@ export function createTestBrowserRuntimeComposition( const renderTraceSlotsByNavigation = new Map>(); let acceptedBrowserBoot: AcceptedBrowserBoot | undefined; 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 { + gptDiagnosticsFacts?.publish( + observation as unknown as Readonly + ); + } catch { + // GPT diagnostics never affect an already-committed adapter observation. + } + return; + } if ( observation['kind'] !== 'render_attempt' || typeof observation['slotId'] !== 'string' || @@ -1201,10 +1219,14 @@ export function createTestBrowserRuntimeComposition( const prepared = preparedBrowserServices; if (!prepared) throw new Error('Browser services are unavailable'); const facts = gptDiagnosticsFacts; - if (facts) { + const bus = diagnosticsBus; + if (facts && bus) { const releaseCapture = activateGptDiagnosticsFactCapture( composition.adapters.googletag, - facts + Object.freeze({ + publish: (fact: Readonly) => + bus.publish(fact as unknown as DiagnosticsObservation), + }) ); if (!releaseCapture) throw new Error('GPT diagnostics capture is unavailable'); context.onDispose(releaseCapture); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 176c3136c..09ce72b1f 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -49,6 +49,7 @@ import { createSourcepointIntegrationRegistration } from '../../src/integrations import { createTestlightIntegrationRegistration } from '../../src/integrations/testlight/module'; import { publicLog } from '../../src/kernel/fallback'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import type { IntegrationPrepareContext } from '../../src/kernel/integration_registry'; import { createRenderAttempt, type CommittedRenderArtifact, @@ -946,6 +947,93 @@ describe('browser composition', () => { expect(gpt.listenerInventory()).toEqual([]); }); + it('publishes committed GPT facts through the kernel diagnostics bus', async () => { + const releaseId = 'a'.repeat(64); + const gpt = synchronousGptAdapter(); + const observations: Readonly>[] = []; + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [ + { id: 'diagnostics_probe', required: true }, + { id: 'gpt_diagnostics', required: true }, + ], + }, + knownIntegrationIds: Object.freeze(['diagnostics_probe', 'gpt_diagnostics']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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({ + id: 'diagnostics_probe', + release: releaseId, + prepare: ({ interfaces, onDispose }: IntegrationPrepareContext) => { + const diagnostics = interfaces['diagnostics'] as { + subscribe( + id: string, + listener: (observation: Readonly>) => void + ): (() => void) | undefined; + }; + const release = diagnostics.subscribe('diagnostics_probe', (observation) => + observations.push(observation) + ); + if (!release) throw new Error('Expected the diagnostics bus subscription'); + onDispose(release); + return { activate: vi.fn() }; + }, + }) + ).toBe(true); + expect( + composition.runtime.registerIntegration( + createGptDiagnosticsIntegrationRegistration(releaseId) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + + const observedSlot = Object.freeze({ + getSlotElementId: () => 'bus-slot', + getAdUnitPath: () => '/example/bus-slot', + }); + gpt.emit('slotRenderEnded', { slot: observedSlot, isEmpty: false }); + + await vi.waitFor(() => + expect(observations).toContainEqual( + expect.objectContaining({ + kind: 'slotRenderEnded', + slot: observedSlot, + isEmpty: false, + }) + ) + ); + } finally { + composition.runtime.dispose(); + } + }); + it('injects GPT and Prebid module boundaries with only server-frozen configuration', async () => { const releaseId = 'a'.repeat(64); const target = {}; From 55341cf26ca6bfa8938524d778b1818841f66f66 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:22:43 -0700 Subject: [PATCH 145/194] Document TSJS manifest errors --- crates/trusted-server-core/src/tsjs.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 759f7216c..1d71a981d 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -10,6 +10,11 @@ use crate::error::TrustedServerError; /// `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")); From 624f27082041c9d44a8793a4a07835928eeb2e25 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:25:29 -0700 Subject: [PATCH 146/194] Format resilient TSJS modules --- .../trusted-server-js/lib/src/composition/browser.ts | 4 +--- .../lib/src/integrations/didomi/module.ts | 12 ++++++------ .../lib/src/integrations/osano/consent_mirror.ts | 5 +---- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index daa503496..2698c870c 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -443,9 +443,7 @@ export function createTestBrowserRuntimeComposition( observation['kind'] === 'slotVisibilityChanged' ) { try { - gptDiagnosticsFacts?.publish( - observation as unknown as Readonly - ); + gptDiagnosticsFacts?.publish(observation as unknown as Readonly); } catch { // GPT diagnostics never affect an already-committed adapter observation. } diff --git a/crates/trusted-server-js/lib/src/integrations/didomi/module.ts b/crates/trusted-server-js/lib/src/integrations/didomi/module.ts index 871bd9c51..4f8522310 100644 --- a/crates/trusted-server-js/lib/src/integrations/didomi/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/didomi/module.ts @@ -37,12 +37,12 @@ function didomiBootConfig(candidate: unknown): candidate is Readonly<{ proxyPath 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 && + '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('#') ); diff --git a/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts b/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts index cb5b40516..88c6a983d 100644 --- a/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts +++ b/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts @@ -437,10 +437,7 @@ function installOsanoListeners(): boolean { return true; } - if ( - typeof cm.addEventListener !== 'function' || - typeof cm.removeEventListener !== 'function' - ) { + if (typeof cm.addEventListener !== 'function' || typeof cm.removeEventListener !== 'function') { return false; } From 45de84d3c3d2c4d602832e876f03a673d822dbe7 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:26:12 -0700 Subject: [PATCH 147/194] Fix duplicate Prebid artifact equality --- .../lib/build-prebid-external.mjs | 3 +- .../test/prebid-artifact-integration.test.mjs | 53 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index 9d0b14aa4..75b5ee5fa 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -303,11 +303,12 @@ function renderExternalWrapper(bundleCode, stamp) { '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 { dom.window.close(); }); + 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: [] };'); + const warn = vi.fn(); + pageWindow.console.warn = warn; + const firstBytes = Buffer.from(bundleCode, 'utf8'); + const duplicateBytes = Buffer.from(bundleCode, 'utf8'); + expect(firstBytes).not.toBe(duplicateBytes); + expect(firstBytes.equals(duplicateBytes)).toBe(true); + + pageWindow.eval(firstBytes.toString('utf8')); + const firstBinding = pageWindow.pbjs; + const firstRequestBids = firstBinding.requestBids; + const firstRegisterBidAdapter = firstBinding.registerBidAdapter; + const firstStamp = firstBinding.__trustedServerArtifactV1; + pageWindow.eval(duplicateBytes.toString('utf8')); + + expect(pageWindow.pbjs).toBe(firstBinding); + expect(pageWindow.pbjs.requestBids).toBe(firstRequestBids); + expect(pageWindow.pbjs.registerBidAdapter).toBe(firstRegisterBidAdapter); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(firstStamp); + expect(warn).not.toHaveBeenCalled(); + expect(watchdogs).toHaveLength(2); + + const processQueue = vi.fn(firstBinding.processQueue.bind(firstBinding)); + firstBinding.processQueue = processQueue; + for (const watchdog of watchdogs) { + watchdog(); + watchdog(); + } + expect(processQueue).toHaveBeenCalledTimes(2); + dom.window.close(); + }); + it('refuses a different valid artifact without disturbing the working binding', () => { const dom = new JSDOM('', { url: 'https://pub.example.com/article', From 5c124c5bf221c55a0f9dc63b66aca77e222832be Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:26:47 -0700 Subject: [PATCH 148/194] Harden creative boot and click ownership --- .../lib/src/integrations/creative/click.ts | 44 +++++-- .../lib/src/integrations/creative/module.ts | 3 +- .../lib/src/kernel/fallback.ts | 16 ++- .../lib/test/composition/browser.test.ts | 120 ++++++++++++++++++ .../test/integrations/creative/click.test.ts | 92 ++++++++++++++ .../test/integrations/creative/module.test.ts | 19 ++- .../lib/test/kernel/fallback.test.ts | 75 +++++++++++ 7 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/kernel/fallback.test.ts diff --git a/crates/trusted-server-js/lib/src/integrations/creative/click.ts b/crates/trusted-server-js/lib/src/integrations/creative/click.ts index a1e496705..15fa41e15 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts @@ -165,7 +165,12 @@ 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, + isActive: () => boolean +): Promise { const addKeys = Object.keys(diff.add); const delKeys = diff.del; if (addKeys.length === 0 && delKeys.length === 0) { @@ -175,6 +180,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); @@ -195,6 +201,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 { @@ -206,6 +213,7 @@ 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); @@ -216,9 +224,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); @@ -229,7 +239,11 @@ 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, + isActive: () => boolean +): Promise { const orig = canonFromFirstPartyClick(tsClickStr); if (!orig) return tsClickStr; @@ -266,7 +280,7 @@ async function computeFinalUrl(a: AnchorLike, tsClickStr: string): Promise { - let finalUrl = await computeFinalUrl(anchor, tsClickStr); +async function rebuildIfNeeded( + anchor: AnchorLike, + tsClickStr: string, + isActive: () => boolean +): Promise { + let finalUrl = await computeFinalUrl(anchor, tsClickStr, isActive); + if (!isActive()) return tsClickStr; if (finalUrl === tsClickStr) { await delay(); - finalUrl = await computeFinalUrl(anchor, tsClickStr); + if (!isActive()) return tsClickStr; + finalUrl = await computeFinalUrl(anchor, tsClickStr, isActive); } return finalUrl; } @@ -352,7 +376,7 @@ async function guardNavigation( isMiddle: boolean, isActive: () => boolean ): Promise { - const finalUrl = await rebuildIfNeeded(anchor, tsClickStr); + const finalUrl = await rebuildIfNeeded(anchor, tsClickStr, isActive); if (!isActive()) return; if (finalUrl && finalUrl !== tsClickStr) { persistRebuiltClick(anchor, finalUrl); @@ -392,7 +416,7 @@ function monitorAnchorMutations(isActive: () => boolean): CreativeGuardHandle { if (!isActive()) return; const tsClickStr = anchor.getAttribute('data-tsclick') || ''; if (!tsClickStr) return; - void rebuildIfNeeded(anchor, tsClickStr) + void rebuildIfNeeded(anchor, tsClickStr, isActive) .then((finalUrl) => { if (!isActive()) return; if (finalUrl && finalUrl !== tsClickStr) { diff --git a/crates/trusted-server-js/lib/src/integrations/creative/module.ts b/crates/trusted-server-js/lib/src/integrations/creative/module.ts index f797267b9..c9d21d3b6 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/module.ts @@ -40,7 +40,8 @@ function readCreativeBoot(candidate: unknown): Readonly | undefi return values['version'] === 1 && typeof values['enabled'] === 'boolean' && typeof values['clickGuard'] === 'boolean' && - typeof values['renderGuard'] === 'boolean' + typeof values['renderGuard'] === 'boolean' && + (values['enabled'] || (!values['clickGuard'] && !values['renderGuard'])) ? (candidate as Readonly) : undefined; } catch { diff --git a/crates/trusted-server-js/lib/src/kernel/fallback.ts b/crates/trusted-server-js/lib/src/kernel/fallback.ts index 00ecf0206..84b777d5e 100644 --- a/crates/trusted-server-js/lib/src/kernel/fallback.ts +++ b/crates/trusted-server-js/lib/src/kernel/fallback.ts @@ -47,6 +47,16 @@ function ownDataRecord(value: unknown): Record | undefined { } } +function ownPlainDataRecord(value: unknown): Record | undefined { + const record = ownDataRecord(value); + if (!record) return undefined; + try { + return Object.getPrototypeOf(value) === Object.prototype ? record : undefined; + } catch { + return undefined; + } +} + function exactKeys(record: Record, keys: readonly string[]): boolean { const actual = Object.keys(record); return actual.length === keys.length && actual.every((key) => keys.includes(key)); @@ -138,7 +148,7 @@ export function buildKernelBoot( record.cachePolicy === undefined ? undefined : parseCachePolicy(record.cachePolicy); if (record.cachePolicy !== undefined && !cachePolicy) return undefined; const auctionProjection = parseBrowserAuctionProjectionV1(record.auctionProjection, cachePolicy); - const creative = ownDataRecord(record.creative); + const creative = ownPlainDataRecord(record.creative); const diagnostics = ownDataRecord(record.diagnostics); const gptDiagnostics = ownDataRecord(diagnostics?.gpt); if ( @@ -149,6 +159,7 @@ export function buildKernelBoot( typeof creative.enabled !== 'boolean' || typeof creative.clickGuard !== 'boolean' || typeof creative.renderGuard !== 'boolean' || + (!creative.enabled && (creative.clickGuard || creative.renderGuard)) || !diagnostics || !exactKeys(diagnostics, ['version', 'renderTraceOverlay', 'gpt']) || diagnostics.version !== 1 || @@ -160,7 +171,10 @@ export function buildKernelBoot( return undefined; } const diagnosticsModule = manifest.integrations.filter(({ id }) => id === 'gpt_diagnostics'); + const creativeModule = manifest.integrations.filter(({ id }) => id === 'creative'); if ( + (creative.enabled && creativeModule.length !== 1) || + (!creative.enabled && creativeModule.length !== 0) || (gptDiagnostics.active && diagnosticsModule.length !== 1) || (!gptDiagnostics.active && diagnosticsModule.length !== 0) ) { diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 09ce72b1f..3352eae5b 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -1388,6 +1388,126 @@ describe('browser composition', () => { 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: { + version: 1, + releaseId, + integrations: [{ id: 'creative', required: true }], + }, + knownIntegrationIds: Object.freeze(['creative']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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(createCreativeIntegrationRegistration(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: { + version: 1, + releaseId, + integrations: manifestIds.map((id) => ({ id, required: true })), + }, + knownIntegrationIds: Object.freeze(['creative']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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(createCreativeIntegrationRegistration(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'); 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 beb9411b1..ffa55987f 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 @@ -277,4 +277,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 importCreativeModule(); + 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 importCreativeModule(); + 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/module.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts index 22393ce85..7b0eee040 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts @@ -97,10 +97,13 @@ describe('transactional creative integration module', () => { expect(release).toHaveBeenCalledTimes(1); }); - it.each([ - Object.freeze({ version: 1, enabled: false, clickGuard: true, renderGuard: true }), - Object.freeze({ version: 1, enabled: true, clickGuard: false, renderGuard: false }), - ])('performs no runtime work for an inactive creative boot %#', async (config) => { + 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 activate = vi.fn(); const start = vi.fn(); const registry = createIntegrationRegistry({ @@ -192,6 +195,14 @@ describe('transactional creative integration module', () => { ), ], ['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(); 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..be6c07539 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/fallback.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { buildKernelBoot } from '../../src/kernel/fallback'; + +const RELEASE_ID = 'a'.repeat(64); + +function manifest(ids: readonly string[]) { + return { + version: 1 as const, + releaseId: RELEASE_ID, + integrations: ids.map((id) => ({ id, required: true as const })), + }; +} + +function boot(creative: unknown) { + return { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }; +} + +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); + }); +}); From eec0b9982ebaeed5897f7b348fd732acc291259c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:39:39 -0700 Subject: [PATCH 149/194] Isolate creative lifecycle ownership --- .../lib/src/integrations/creative/click.ts | 59 +++++++++++-------- .../lib/src/integrations/creative/startup.ts | 10 +++- .../test/integrations/creative/click.test.ts | 51 ++++++++++++++++ .../integrations/creative/startup.test.ts | 36 +++++++++++ 4 files changed, 131 insertions(+), 25 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/creative/click.ts b/crates/trusted-server-js/lib/src/integrations/creative/click.ts index 15fa41e15..2ddb4cef5 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts @@ -10,15 +10,7 @@ import type { CreativeGuardHandle } from './startup'; type AnchorLike = HTMLAnchorElement | HTMLAreaElement; type Canon = { base: string; params: Record }; 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 { @@ -169,6 +161,7 @@ async function rebuildClick( a: AnchorLike, tsClickStr: string, diff: Diff, + pendingRebuilds: PendingRebuilds, isActive: () => boolean ): Promise { const addKeys = Object.keys(diff.add); @@ -216,7 +209,7 @@ async function rebuildClick( 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, @@ -242,6 +235,7 @@ async function rebuildClick( async function computeFinalUrl( a: AnchorLike, tsClickStr: string, + pendingRebuilds: PendingRebuilds, isActive: () => boolean ): Promise { const orig = canonFromFirstPartyClick(tsClickStr); @@ -280,7 +274,7 @@ async function computeFinalUrl( del: diff.del, }); - return rebuildClick(a, tsClickStr, diff, isActive); + return rebuildClick(a, tsClickStr, diff, pendingRebuilds, isActive); } // Resolve a click URL against the pinned trusted base and require an http(s) @@ -325,7 +319,11 @@ function navigate(a: AnchorLike, url: string, isMiddle: boolean): void { // compare against — is only updated when the value is itself a signed // /first-party/click URL. Writing the GET proxy-rebuild fallback there would // make every later canonicalization fail and lose subsequent mutations. -function persistRebuiltClick(anchor: AnchorLike, finalUrl: string): void { +function persistRebuiltClick( + anchor: AnchorLike, + finalUrl: string, + pendingRebuilds: PendingRebuilds +): void { // Persist the validated, absolutized URL — never the raw input. Beyond // enforcing the http(s) allowlist, an absolute URL keeps the anchor's // default navigation working inside the srcdoc iframe, where a relative @@ -357,14 +355,15 @@ function persistRebuiltClick(anchor: AnchorLike, finalUrl: string): void { async function rebuildIfNeeded( anchor: AnchorLike, tsClickStr: string, + pendingRebuilds: PendingRebuilds, isActive: () => boolean ): Promise { - let finalUrl = await computeFinalUrl(anchor, tsClickStr, isActive); + let finalUrl = await computeFinalUrl(anchor, tsClickStr, pendingRebuilds, isActive); if (!isActive()) return tsClickStr; if (finalUrl === tsClickStr) { await delay(); if (!isActive()) return tsClickStr; - finalUrl = await computeFinalUrl(anchor, tsClickStr, isActive); + finalUrl = await computeFinalUrl(anchor, tsClickStr, pendingRebuilds, isActive); } return finalUrl; } @@ -374,18 +373,24 @@ async function guardNavigation( anchor: AnchorLike, tsClickStr: string, isMiddle: boolean, + pendingRebuilds: PendingRebuilds, isActive: () => boolean ): Promise { - const finalUrl = await rebuildIfNeeded(anchor, tsClickStr, isActive); + 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, isActive: () => boolean): void { +function handleGuardedClick( + ev: Event, + isMiddle: boolean, + pendingRebuilds: PendingRebuilds, + isActive: () => boolean +): void { const anchor = closestAnchor(ev.target); if (!anchor) return; @@ -396,7 +401,7 @@ function handleGuardedClick(ev: Event, isMiddle: boolean, isActive: () => boolea const runNavigation = () => { if (!isActive()) return; - void guardNavigation(anchor, tsClickStr, isMiddle, isActive).catch((err) => { + 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); @@ -407,7 +412,10 @@ function handleGuardedClick(ev: Event, isMiddle: boolean, isActive: () => boolea } // Observe href/data-tsclick mutations and repair anchors that third parties touch. -function monitorAnchorMutations(isActive: () => boolean): CreativeGuardHandle { +function monitorAnchorMutations( + pendingRebuilds: PendingRebuilds, + isActive: () => boolean +): CreativeGuardHandle { if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') { return Object.freeze({ dispose: () => undefined, scan: () => undefined }); } @@ -416,11 +424,11 @@ function monitorAnchorMutations(isActive: () => boolean): CreativeGuardHandle { if (!isActive()) return; const tsClickStr = anchor.getAttribute('data-tsclick') || ''; if (!tsClickStr) return; - void rebuildIfNeeded(anchor, tsClickStr, isActive) + void rebuildIfNeeded(anchor, tsClickStr, pendingRebuilds, isActive) .then((finalUrl) => { if (!isActive()) return; if (finalUrl && finalUrl !== tsClickStr) { - persistRebuiltClick(anchor, finalUrl); + persistRebuiltClick(anchor, finalUrl, pendingRebuilds); } }) .catch((err) => { @@ -471,17 +479,20 @@ export function installClickGuard(scanInitially = true): CreativeGuardHandle { 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) => { if (!active) return; - handleGuardedClick(ev, false, isActive); + handleGuardedClick(ev, false, pendingRebuilds, isActive); }; const onAuxClick = (ev: MouseEvent) => { if (!active) return; if (ev.button !== 1) return; - handleGuardedClick(ev, true, isActive); + handleGuardedClick(ev, true, pendingRebuilds, isActive); }; document.addEventListener('click', onClick, true); @@ -496,7 +507,7 @@ export function installClickGuard(scanInitially = true): CreativeGuardHandle { mutations?.dispose(); }; try { - mutations = monitorAnchorMutations(isActive); + mutations = monitorAnchorMutations(pendingRebuilds, isActive); const handle = Object.freeze({ dispose, scan: (): void => mutations?.scan(), diff --git a/crates/trusted-server-js/lib/src/integrations/creative/startup.ts b/crates/trusted-server-js/lib/src/integrations/creative/startup.ts index cf5167612..916a3fbe2 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/startup.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/startup.ts @@ -92,7 +92,15 @@ export function createCreativeStartup(options: CreativeStartupOptions): Creative options.document.addEventListener('DOMContentLoaded', readyListener, { once: true }); } } catch (error) { - disposeHandles(); + 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 => { 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 ffa55987f..517e30e55 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 @@ -255,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. 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 index 8a7bf7a30..1989af611 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts @@ -126,6 +126,42 @@ describe('creative startup ownership', () => { 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); From 5f7d37a153072d9216a422f316c6be660ff9adeb Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:46:21 -0700 Subject: [PATCH 150/194] Restore Testlight queue push parity --- .../lib/src/integrations/testlight/module.ts | 8 ++++++-- .../integrations/testlight/module.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/testlight/module.ts b/crates/trusted-server-js/lib/src/integrations/testlight/module.ts index dbbab6951..9dddf69d7 100644 --- a/crates/trusted-server-js/lib/src/integrations/testlight/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/testlight/module.ts @@ -190,11 +190,15 @@ export function createTestlightRuntime( } const pending = ownQueueValues(queue); queue.length = 0; + const nativePush = queue.push.bind(queue); Object.defineProperty(queue, 'push', { configurable: true, enumerable: false, value: (...candidates: unknown[]): number => { - for (const candidate of candidates) { + const length = nativePush(...candidates); + const forwarded = ownQueueValues(queue); + queue.length = 0; + for (const candidate of forwarded) { if (typeof candidate !== 'function') continue; try { dependencies.enqueue(candidate as () => void); @@ -203,7 +207,7 @@ export function createTestlightRuntime( log.debug('testlight shim: queued callback threw', error); } } - return 0; + return length; }, writable: false, }); 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 index a18e6e199..136f2b60f 100644 --- a/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts @@ -50,6 +50,25 @@ describe('transactional Testlight integration module', () => { 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({ From 11aefe9c1171ad6cd27bf231405573327eb341a3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:47:30 -0700 Subject: [PATCH 151/194] Harden GPT diagnostics fact ownership --- .../lib/src/adapters/googletag.ts | 54 ++++++++++- .../trusted-server-js/lib/src/core/trace.ts | 11 ++- .../integrations/gpt_diagnostics/observer.ts | 93 ++++++++++++++----- .../src/integrations/gpt_diagnostics/store.ts | 61 ++++++++---- .../lib/src/kernel/diagnostics.ts | 7 +- .../lib/test/adapters/googletag.test.ts | 22 ++++- .../lib/test/core/trace_runtime.test.ts | 10 ++ .../gpt_diagnostics/facts.test.ts | 11 ++- .../gpt_diagnostics/index.test.ts | 16 ++-- .../gpt_diagnostics/observer.test.ts | 64 ++++++++----- .../gpt_diagnostics/store.test.ts | 16 ++++ .../lib/test/kernel/diagnostics.test.ts | 14 +++ 12 files changed, 292 insertions(+), 87 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index 30779fd7b..fb49c48a3 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -211,7 +211,8 @@ export type GoogletagDiagnosticsEventName = export interface GoogletagDiagnosticsFact { readonly kind: GoogletagDiagnosticsEventName; - readonly slot: object; + readonly observedAtMs: number; + readonly slot: GoogletagDiagnosticsSlotSnapshot; readonly isEmpty?: boolean; readonly size?: readonly [number, number]; readonly isBackfill?: boolean; @@ -219,6 +220,13 @@ export interface GoogletagDiagnosticsFact { readonly inViewPercentage?: number; } +/** Frozen, non-authoritative identity and metadata captured from one physical GPT slot. */ +export interface GoogletagDiagnosticsSlotSnapshot { + readonly token: object; + readonly elementId?: string; + readonly adUnitPath?: string; +} + export type GoogletagDiagnosticsObserver = (fact: Readonly) => void; /** Browser surface owned by the concrete GPT adapter. */ @@ -861,6 +869,7 @@ export function createBrowserGoogletagAdapter( const targetingObservations = new WeakMap(); const facadeCalls = new WeakMap<(...arguments_: unknown[]) => unknown, number>(); const bindingTokens = new WeakMap(); + const diagnosticsSlots = new WeakMap(); const initialLoadReleases = new Map void>(); const initialLoadOwner = Object.freeze({}); let diagnosticsObserver: GoogletagDiagnosticsObserver | undefined; @@ -870,7 +879,8 @@ export function createBrowserGoogletagAdapter( const diagnosticFact = ( eventType: string, - event: unknown + event: unknown, + observedAtMs: number ): Readonly | undefined => { try { if ((typeof event !== 'object' || event === null) && typeof event !== 'function') { @@ -880,7 +890,30 @@ export function createBrowserGoogletagAdapter( if ((typeof slot !== 'object' || slot === null) && typeof slot !== 'function') { return undefined; } - const base = { kind: eventType, slot: slot as object }; + const physicalSlot = slot as object; + let safeSlot = weakMapValue(diagnosticsSlots, physicalSlot); + if (!safeSlot) { + 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 token = Object.freeze(Object.create(null) as object); + const elementId = optionalStringCall('getSlotElementId'); + const adUnitPath = optionalStringCall('getAdUnitPath'); + safeSlot = Object.freeze({ + token, + ...(elementId === undefined ? {} : { elementId }), + ...(adUnitPath === undefined ? {} : { adUnitPath }), + }); + setWeakMapValue(diagnosticsSlots, physicalSlot, safeSlot); + } + const base = { kind: eventType, observedAtMs, slot: safeSlot }; switch (eventType) { case 'slotRequested': case 'slotResponseReceived': @@ -931,7 +964,20 @@ export function createBrowserGoogletagAdapter( const publishDiagnostics = (eventType: string, event: unknown): void => { const observer = diagnosticsObserver; if (!observer || disposed) return; - const fact = diagnosticFact(eventType, event); + 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); if (!fact) return; try { observer(fact); diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 20a99a20f..836c7408f 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -932,6 +932,7 @@ export function createRenderTraceDiagnostics( options: RenderTraceRuntimeOptions = {} ): RenderTraceRuntimeOwner { const current = new Map>(); + const counts = new Map(); const history: Array> = []; const recordsBySequence = new Map>(); const subscribers = new Map(); @@ -1037,9 +1038,16 @@ export function createRenderTraceDiagnostics( } catch { at = Date.now(); } + const previousCount = counts.get(input.slotId) ?? 0; + if (!counts.has(input.slotId) && counts.size >= MAX_RENDER_TRACE_SLOTS) { + const oldestCount = counts.keys().next().value as string | undefined; + if (oldestCount !== undefined) counts.delete(oldestCount); + } + counts.delete(input.slotId); + counts.set(input.slotId, previousCount + 1); const committed = copyRenderTraceRecord({ ...input, - count: (previous?.count ?? 0) + 1, + count: previousCount + 1, seq: (sequence += 1), at, }); @@ -1166,6 +1174,7 @@ export function createRenderTraceDiagnostics( current.clear(); history.length = 0; recordsBySequence.clear(); + counts.clear(); presentation.dispose(); }; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts index 472ee30a6..0826b8bc7 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts @@ -6,12 +6,20 @@ import type { GptDiagnosticsSlotLike, GptRenderFacts } from './store'; export interface GptDiagnosticsObserverStore { markGptObserved(): void; - recordSlotRequested(slot: GptDiagnosticsSlotLike): void; - recordSlotResponseReceived(slot: GptDiagnosticsSlotLike): void; - recordSlotRenderEnded(slot: GptDiagnosticsSlotLike, facts: GptRenderFacts): void; - recordSlotOnload(slot: GptDiagnosticsSlotLike): void; - recordImpressionViewable(slot: GptDiagnosticsSlotLike): void; - recordSlotVisibilityChanged(slot: GptDiagnosticsSlotLike, percentage: number): void; + recordSlotRequested(slot: GptDiagnosticsSlotLike, timestampMs?: number): void; + recordSlotResponseReceived(slot: GptDiagnosticsSlotLike, timestampMs?: number): void; + recordSlotRenderEnded( + slot: GptDiagnosticsSlotLike, + facts: GptRenderFacts, + timestampMs?: number + ): void; + recordSlotOnload(slot: GptDiagnosticsSlotLike, timestampMs?: number): void; + recordImpressionViewable(slot: GptDiagnosticsSlotLike, timestampMs?: number): void; + recordSlotVisibilityChanged( + slot: GptDiagnosticsSlotLike, + percentage: number, + timestampMs?: number + ): void; } interface ObserverLogger { @@ -27,6 +35,7 @@ export class GptDiagnosticsObserver { private readonly store: GptDiagnosticsObserverStore; private readonly logger: ObserverLogger; private started = false; + private observed = false; constructor(store: GptDiagnosticsObserverStore, options: ObserverOptions = {}) { this.store = store; @@ -36,47 +45,87 @@ export class GptDiagnosticsObserver { start(): void { if (this.started) return; this.started = true; - this.handle('activation', () => this.store.markGptObserved()); } consume(fact: Readonly): void { this.start(); + if (!this.observed) { + this.observed = true; + this.handle('observation', () => this.store.markGptObserved()); + } const slot = fact.slot as GptDiagnosticsSlotLike; + const observedAtMs = + typeof fact.observedAtMs === 'number' && Number.isFinite(fact.observedAtMs) + ? fact.observedAtMs + : undefined; switch (fact.kind) { case 'slotRequested': - this.handle(fact.kind, () => this.store.recordSlotRequested(slot)); + this.handle(fact.kind, () => + observedAtMs === undefined + ? this.store.recordSlotRequested(slot) + : this.store.recordSlotRequested(slot, observedAtMs) + ); return; case 'slotResponseReceived': - this.handle(fact.kind, () => this.store.recordSlotResponseReceived(slot)); + this.handle(fact.kind, () => + observedAtMs === undefined + ? this.store.recordSlotResponseReceived(slot) + : this.store.recordSlotResponseReceived(slot, observedAtMs) + ); return; case 'slotRenderEnded': this.handle(fact.kind, () => - this.store.recordSlotRenderEnded(slot, { - isEmpty: fact.isEmpty, - size: fact.size ? ([...fact.size] as Size) : undefined, - isBackfill: fact.isBackfill, - slotContentChanged: fact.slotContentChanged, - }) + observedAtMs === undefined + ? this.store.recordSlotRenderEnded(slot, { + isEmpty: fact.isEmpty, + size: fact.size ? ([...fact.size] as Size) : undefined, + isBackfill: fact.isBackfill, + slotContentChanged: fact.slotContentChanged, + }) + : this.store.recordSlotRenderEnded( + slot, + { + isEmpty: fact.isEmpty, + size: fact.size ? ([...fact.size] as Size) : undefined, + isBackfill: fact.isBackfill, + slotContentChanged: fact.slotContentChanged, + }, + observedAtMs + ) ); return; case 'slotOnload': - this.handle(fact.kind, () => this.store.recordSlotOnload(slot)); + this.handle(fact.kind, () => + observedAtMs === undefined + ? this.store.recordSlotOnload(slot) + : this.store.recordSlotOnload(slot, observedAtMs) + ); return; case 'impressionViewable': - this.handle(fact.kind, () => this.store.recordImpressionViewable(slot)); + this.handle(fact.kind, () => + observedAtMs === undefined + ? this.store.recordImpressionViewable(slot) + : this.store.recordImpressionViewable(slot, observedAtMs) + ); return; case 'slotVisibilityChanged': this.handle(fact.kind, () => - this.store.recordSlotVisibilityChanged( - slot, - typeof fact.inViewPercentage === 'number' ? fact.inViewPercentage : Number.NaN - ) + observedAtMs === undefined + ? this.store.recordSlotVisibilityChanged( + slot, + typeof fact.inViewPercentage === 'number' ? fact.inViewPercentage : Number.NaN + ) + : this.store.recordSlotVisibilityChanged( + slot, + typeof fact.inViewPercentage === 'number' ? fact.inViewPercentage : Number.NaN, + observedAtMs + ) ); } } private handle( - kind: GoogletagDiagnosticsFact['kind'] | 'activation', + kind: GoogletagDiagnosticsFact['kind'] | 'observation', callback: () => void ): void { try { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts index c5efe25cd..c0cff3276 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts @@ -22,6 +22,9 @@ const CALLBACK_KINDS: GptDiagnosticsCallbackKind[] = [ ]; export interface GptDiagnosticsSlotLike { + readonly token?: object | undefined; + readonly elementId?: string | undefined; + readonly adUnitPath?: string | undefined; getSlotElementId?: (() => string) | undefined; getAdUnitPath?: (() => string) | undefined; } @@ -165,8 +168,8 @@ export class GptDiagnosticsStore { return () => this.listeners.delete(listener); } - recordSlotRequested(slot: GptDiagnosticsSlotLike): void { - const timestampMs = this.timestamp(); + recordSlotRequested(slot: GptDiagnosticsSlotLike, observedAtMs?: number): void { + const timestampMs = this.timestamp(observedAtMs); const record = this.prepareCallback('slotRequested', slot, timestampMs); if (!record) return; @@ -175,8 +178,9 @@ export class GptDiagnosticsStore { this.metadata.evictedRequestCycles += 1; } - const requestNumber = (this.requestNumbers.get(slot) ?? 0) + 1; - this.requestNumbers.set(slot, requestNumber); + const identity = slot.token ?? slot; + const requestNumber = (this.requestNumbers.get(identity) ?? 0) + 1; + this.requestNumbers.set(identity, requestNumber); record.requests.push({ requestNumber, requestedAtMs: timestampMs, @@ -187,8 +191,8 @@ export class GptDiagnosticsStore { this.notify(); } - recordSlotResponseReceived(slot: GptDiagnosticsSlotLike): void { - const timestampMs = this.timestamp(); + recordSlotResponseReceived(slot: GptDiagnosticsSlotLike, observedAtMs?: number): void { + const timestampMs = this.timestamp(observedAtMs); this.matchCycle( 'slotResponseReceived', slot, @@ -213,8 +217,12 @@ export class GptDiagnosticsStore { ); } - recordSlotRenderEnded(slot: GptDiagnosticsSlotLike, facts: GptRenderFacts): void { - const timestampMs = this.timestamp(); + recordSlotRenderEnded( + slot: GptDiagnosticsSlotLike, + facts: GptRenderFacts, + observedAtMs?: number + ): void { + const timestampMs = this.timestamp(observedAtMs); this.matchCycle( 'slotRenderEnded', slot, @@ -244,8 +252,8 @@ export class GptDiagnosticsStore { ); } - recordSlotOnload(slot: GptDiagnosticsSlotLike): void { - const timestampMs = this.timestamp(); + recordSlotOnload(slot: GptDiagnosticsSlotLike, observedAtMs?: number): void { + const timestampMs = this.timestamp(observedAtMs); this.matchCycle( 'slotOnload', slot, @@ -262,8 +270,8 @@ export class GptDiagnosticsStore { ); } - recordImpressionViewable(slot: GptDiagnosticsSlotLike): void { - const timestampMs = this.timestamp(); + recordImpressionViewable(slot: GptDiagnosticsSlotLike, observedAtMs?: number): void { + const timestampMs = this.timestamp(observedAtMs); this.matchCycle( 'impressionViewable', slot, @@ -288,8 +296,12 @@ export class GptDiagnosticsStore { ); } - recordSlotVisibilityChanged(slot: GptDiagnosticsSlotLike, percentage: number): void { - const timestampMs = this.timestamp(); + recordSlotVisibilityChanged( + slot: GptDiagnosticsSlotLike, + percentage: number, + observedAtMs?: number + ): void { + const timestampMs = this.timestamp(observedAtMs); const record = this.prepareCallback('slotVisibilityChanged', slot, timestampMs); if (!record) return; @@ -354,9 +366,11 @@ export class GptDiagnosticsStore { }; } - private timestamp(): number { + private timestamp(observedAtMs?: number): number { this.gptObserved = true; - return this.now(); + return typeof observedAtMs === 'number' && Number.isFinite(observedAtMs) + ? observedAtMs + : this.now(); } private prepareCallback( @@ -365,7 +379,8 @@ export class GptDiagnosticsStore { timestampMs: number ): MutableSlotRecord | undefined { this.coverage[kind].observed += 1; - const existingNumber = this.slotNumbers.get(slot); + const identity = slot.token ?? slot; + const existingNumber = this.slotNumbers.get(identity); if (existingNumber !== undefined) { const existingRecord = this.slots.get(existingNumber); if (existingRecord) { @@ -404,7 +419,7 @@ export class GptDiagnosticsStore { runtimeSlotNumber, requests: [], }; - this.slotNumbers.set(slot, runtimeSlotNumber); + this.slotNumbers.set(identity, runtimeSlotNumber); this.refreshSlotMetadata(record, slot); this.slots.set(runtimeSlotNumber, record); this.slotOrder.push(runtimeSlotNumber); @@ -413,10 +428,16 @@ export class GptDiagnosticsStore { } private refreshSlotMetadata(record: MutableSlotRecord, slot: GptDiagnosticsSlotLike): void { - record.slotElementId ??= optionalNonEmptyString( + record.slotElementId ??= + (typeof slot.elementId === 'string' && slot.elementId.length > 0 + ? slot.elementId + : undefined) ?? optionalNonEmptyString( typeof slot.getSlotElementId === 'function' ? slot.getSlotElementId.bind(slot) : undefined ); - record.adUnitPath ??= optionalNonEmptyString( + record.adUnitPath ??= + (typeof slot.adUnitPath === 'string' && slot.adUnitPath.length > 0 + ? slot.adUnitPath + : undefined) ?? optionalNonEmptyString( typeof slot.getAdUnitPath === 'function' ? slot.getAdUnitPath.bind(slot) : undefined ); } diff --git a/crates/trusted-server-js/lib/src/kernel/diagnostics.ts b/crates/trusted-server-js/lib/src/kernel/diagnostics.ts index 11fd03f9b..08ff3f12e 100644 --- a/crates/trusted-server-js/lib/src/kernel/diagnostics.ts +++ b/crates/trusted-server-js/lib/src/kernel/diagnostics.ts @@ -56,17 +56,16 @@ function recursivelyFrozenRecord(candidate: unknown): candidate is DiagnosticsOb const visited = new Set(); let nodes = 0; const visit = (value: unknown, depth: number): boolean => { - if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return true; + if (typeof value === 'function') return false; + if (typeof value !== 'object' || value === null) return true; if (visited.has(value)) return true; if (depth > MAX_OBSERVATION_DEPTH || nodes >= MAX_OBSERVATION_NODES) return false; visited.add(value); nodes += 1; try { - if (typeof value === 'function') return true; const prototype = Object.getPrototypeOf(value) as unknown; if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) { - // GPT physical-slot objects are opaque identities, not diagnostic data. - return true; + return false; } if (!Object.isFrozen(value)) return false; const keys = Reflect.ownKeys(value); diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index ed701f29c..1aadcdae0 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -1105,7 +1105,8 @@ describe('browser googletag adapter readiness', () => { it('publishes frozen diagnostics facts after the sole adapter listener completes', async () => { const ready = createReadyGoogletag(); - const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const performance = { now: vi.fn(() => 42.25) }; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag, performance }); const order: string[] = []; const facts: unknown[] = []; const releaseDiagnostics = adapter.observeDiagnostics?.((fact) => { @@ -1122,7 +1123,11 @@ describe('browser googletag adapter readiness', () => { }) ).result; expect(ready.pubads.addEventListener).toHaveBeenCalledTimes(1); - const slot = Object.freeze({ id: 'fictional-slot' }); + const slot = Object.freeze({ + getSlotElementId: () => 'fictional-slot', + getAdUnitPath: () => '/example/fictional-slot', + setTargeting: vi.fn(), + }); const emit = (event: unknown): void => { for (const listener of ready.listeners.get('slotRenderEnded') ?? []) listener(event); }; @@ -1140,7 +1145,12 @@ describe('browser googletag adapter readiness', () => { expect(facts).toEqual([ { kind: 'slotRenderEnded', - slot, + observedAtMs: 42.25, + slot: { + token: expect.any(Object), + elementId: 'fictional-slot', + adUnitPath: '/example/fictional-slot', + }, isEmpty: false, size: [300, 250], isBackfill: true, @@ -1149,6 +1159,12 @@ describe('browser googletag adapter readiness', () => { ]); expect(Object.isFrozen(facts[0])).toBe(true); expect(Object.isFrozen((facts[0] as { size: unknown }).size)).toBe(true); + const safeSlot = (facts[0] as { slot: Record }).slot; + expect(Object.isFrozen(safeSlot)).toBe(true); + expect(Object.isFrozen(safeSlot['token'])).toBe(true); + expect(Reflect.ownKeys(safeSlot).sort()).toEqual(['adUnitPath', 'elementId', 'token']); + expect(Object.values(safeSlot).some((value) => typeof value === 'function')).toBe(false); + expect(safeSlot).not.toBe(slot); releaseDiagnostics?.(); emit({ slot, isEmpty: true }); 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 index 0f1f9da8c..ef63e0065 100644 --- a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -148,6 +148,16 @@ describe('render trace diagnostics runtime', () => { 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('retains impression bookkeeping and refuses truth-weakening enrichment', () => { const { owner } = harness(); const record = owner.record({ 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 index dd21c36b8..ed19998b5 100644 --- 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 @@ -12,7 +12,14 @@ import { } from '../../../src/integrations/gpt_diagnostics/facts'; function fact(index: number): Readonly { - return Object.freeze({ kind: 'slotRequested', slot: Object.freeze({ index }) }); + 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', () => { @@ -28,7 +35,7 @@ describe('GPT diagnostics fact transport', () => { const received: number[] = []; const release = buffer.activate((item) => { - received.push((item.slot as { index: number }).index); + received.push(Number(item.slot.elementId?.slice('slot-'.length))); }); expect(received).toHaveLength(512); 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 8f9ebc7f2..b0ed27ca8 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 @@ -5,24 +5,20 @@ import { createGptDiagnosticsFactBuffer } from '../../../src/integrations/gpt_di import { createGptDiagnosticsRuntime } from '../../../src/integrations/gpt_diagnostics'; import { GPT_DIAGNOSTICS_HOST_ID } from '../../../src/integrations/gpt_diagnostics/overlay'; -interface FakeSlot { - getSlotElementId(): string; - getAdUnitPath(): string; -} - -function slot(id: string): FakeSlot { +function slot(id: string): GoogletagDiagnosticsFact['slot'] { return Object.freeze({ - getSlotElementId: () => id, - getAdUnitPath: () => `/example/site/${id}`, + token: Object.freeze(Object.create(null) as object), + elementId: id, + adUnitPath: `/example/site/${id}`, }); } function fact( kind: GoogletagDiagnosticsFact['kind'], - observedSlot: object, + observedSlot: GoogletagDiagnosticsFact['slot'], fields: Partial = {} ): Readonly { - return Object.freeze({ kind, slot: observedSlot, ...fields }); + return Object.freeze({ kind, observedAtMs: 1, slot: observedSlot, ...fields }); } beforeEach(() => { 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 ca934a177..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,11 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; -import type { GoogletagDiagnosticsFact } from '../../../src/adapters/googletag'; +import type { + GoogletagDiagnosticsFact, + GoogletagDiagnosticsSlotSnapshot, +} from '../../../src/adapters/googletag'; import { GptDiagnosticsObserver, type GptDiagnosticsObserverStore, } from '../../../src/integrations/gpt_diagnostics/observer'; -import type { GptDiagnosticsSlotLike } from '../../../src/integrations/gpt_diagnostics/store'; function fakeStore(): GptDiagnosticsObserverStore { return { @@ -19,30 +21,31 @@ function fakeStore(): GptDiagnosticsObserverStore { }; } -function fakeSlot(): GptDiagnosticsSlotLike { +function fakeSlot(): GoogletagDiagnosticsSlotSnapshot { return Object.freeze({ - getSlotElementId: () => 'ad-slot-example', - getAdUnitPath: () => '/example/site/banner', + token: Object.freeze(Object.create(null) as object), + elementId: 'ad-slot-example', + adUnitPath: '/example/site/banner', }); } function fact( kind: GoogletagDiagnosticsFact['kind'], - slot: object, + slot: GoogletagDiagnosticsFact['slot'], fields: Partial = {} ): Readonly { - return Object.freeze({ kind, slot, ...fields }); + return Object.freeze({ kind, observedAtMs: 1, slot, ...fields }); } describe('GptDiagnosticsObserver', () => { - it('starts exactly once without reading or mutating any browser global', () => { + it('does not claim GPT observation merely because the diagnostics module activated', () => { const store = fakeStore(); const observer = new GptDiagnosticsObserver(store); observer.start(); observer.start(); - expect(store.markGptObserved).toHaveBeenCalledOnce(); + expect(store.markGptObserved).not.toHaveBeenCalled(); }); it('consumes all six normalized adapter facts', () => { @@ -65,17 +68,36 @@ describe('GptDiagnosticsObserver', () => { observer.consume(fact('slotVisibilityChanged', slot, { inViewPercentage: 42 })); expect(store.markGptObserved).toHaveBeenCalledOnce(); - 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.recordSlotRequested).toHaveBeenCalledWith(slot, 1); + expect(store.recordSlotResponseReceived).toHaveBeenCalledWith(slot, 1); + expect(store.recordSlotRenderEnded).toHaveBeenCalledWith( + slot, + { + 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('passes the immutable adapter callback timestamp through to every store mutation', () => { + const store = fakeStore(); + const observer = new GptDiagnosticsObserver(store); + const slot = fakeSlot(); + const timestamped = Object.freeze({ + kind: 'slotRequested' as const, + slot, + observedAtMs: 123.5, }); - expect(store.recordSlotOnload).toHaveBeenCalledWith(slot); - expect(store.recordImpressionViewable).toHaveBeenCalledWith(slot); - expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(slot, 42); + + observer.consume(timestamped as Readonly); + + expect(store.recordSlotRequested).toHaveBeenCalledWith(slot, 123.5); }); it('records a malformed visibility fact as unmatched instead of dropping its coverage', () => { @@ -84,7 +106,7 @@ describe('GptDiagnosticsObserver', () => { observer.consume(fact('slotVisibilityChanged', fakeSlot())); - expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(expect.any(Object), NaN); + expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(expect.any(Object), NaN, 1); }); it('contains store and logger failures without interrupting later facts', () => { @@ -104,6 +126,6 @@ describe('GptDiagnosticsObserver', () => { expect(() => observer.consume(fact('slotOnload', slot))).not.toThrow(); expect(logger.warn).toHaveBeenCalledOnce(); - expect(store.recordSlotOnload).toHaveBeenCalledWith(slot); + expect(store.recordSlotOnload).toHaveBeenCalledWith(slot, 1); }); }); 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 7c176365d..6cb6b0b9e 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 @@ -82,6 +82,22 @@ describe('GptDiagnosticsStore', () => { assertCoverageEquation(store); }); + 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, 10); + store.recordSlotResponseReceived(slot, 25); + store.recordSlotRenderEnded(slot, { isEmpty: false }, 30); + + expect(store.snapshot().slots[0]?.requests[0]).toMatchObject({ + requestedAtMs: 10, + responseAtMs: 25, + renderAtMs: 30, + durations: { requestToResponseMs: 15, responseToRenderMs: 5, requestToRenderMs: 20 }, + }); + }); + it('matches load and viewability after a render with unknown fill state', () => { let now = 1; const store = new GptDiagnosticsStore({ now: () => now }); diff --git a/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts index db4dcbb90..2557aa19c 100644 --- a/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts @@ -152,6 +152,20 @@ describe('kernel diagnostics bus', () => { bus.dispose(); }); + it('rejects frozen functions and exotic objects instead of transporting capabilities', () => { + const bus = createDiagnosticsBus({ manifest: manifest([]) }); + const callable = Object.freeze(() => undefined); + const exotic = Object.freeze(new (class PublisherSlot {})()); + + expect( + bus.publish(Object.freeze({ kind: 'gpt', slot: callable }) as DiagnosticsObservation) + ).toBe(false); + expect( + bus.publish(Object.freeze({ kind: 'gpt', slot: exotic }) as DiagnosticsObservation) + ).toBe(false); + bus.dispose(); + }); + it('commits to the private core observer before asynchronous module delivery', () => { vi.useFakeTimers(); const order: string[] = []; From 6f26c2f71a0d6be20ba8bdf016adbe573e7a8453 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:55:07 -0700 Subject: [PATCH 152/194] Cancel owned GPT diagnostics frames --- .../integrations/gpt_diagnostics/badges.ts | 63 +++++++++++-- .../integrations/gpt_diagnostics/binding.ts | 63 +++++++++++-- .../integrations/gpt_diagnostics/overlay.ts | 89 +++++++++++++++---- .../gpt_diagnostics/badges.test.ts | 70 ++++++++++++++- .../gpt_diagnostics/binding.test.ts | 64 ++++++++++++- .../gpt_diagnostics/overlay.test.ts | 66 ++++++++++++-- 6 files changed, 370 insertions(+), 45 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts index 970bf48e4..cbce0213c 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts @@ -29,15 +29,21 @@ const BADGE_EDGE_GUTTER_PX = 4; interface BadgeOptions { window?: BadgeWindow | undefined; document?: Document | undefined; - scheduleFrame?: ((callback: () => void) => void) | undefined; + scheduleFrame?: ((callback: () => void) => () => void) | undefined; } -function defaultScheduleFrame(callback: () => void): void { - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => callback()); - } else { - queueMicrotask(callback); +function defaultScheduleFrame(callback: () => void): () => void { + if (typeof requestAnimationFrame === 'function' && typeof cancelAnimationFrame === 'function') { + const frame = requestAnimationFrame(() => callback()); + return () => cancelAnimationFrame(frame); } + let active = true; + queueMicrotask(() => { + if (active) callback(); + }); + return () => { + active = false; + }; } function intersectsViewport(rectangle: DOMRect, window: Window): boolean { @@ -110,7 +116,7 @@ export class GptDiagnosticsBadgeManager { private readonly bindings: BadgeBindings; private readonly window: BadgeWindow; private readonly document: Document; - private readonly scheduleFrame: (callback: () => void) => void; + private readonly scheduleFrame: (callback: () => void) => () => void; private readonly unsubscribeStore: () => void; private readonly unsubscribeBindings: () => void; private readonly slotElementIds = new Set(); @@ -118,6 +124,7 @@ export class GptDiagnosticsBadgeManager { private layer: HTMLElement | undefined; private mutationObserver?: MutationObserver; private resizeObserver?: ResizeObserver; + private cancelScheduledUpdate: (() => void) | undefined; private scheduled = false; private destroyed = false; @@ -192,6 +199,14 @@ export class GptDiagnosticsBadgeManager { destroy(): void { if (this.destroyed) return; this.destroyed = true; + const cancelUpdate = this.cancelScheduledUpdate; + this.cancelScheduledUpdate = undefined; + this.scheduled = false; + try { + cancelUpdate?.(); + } catch { + // Continue releasing every independently owned badge resource. + } this.unsubscribeStore(); this.unsubscribeBindings(); this.window.removeEventListener('scroll', this.scheduleUpdate); @@ -205,10 +220,40 @@ export class GptDiagnosticsBadgeManager { private readonly scheduleUpdate = (): void => { if (this.destroyed || this.scheduled) return; this.scheduled = true; - this.scheduleFrame(() => { + let active = true; + let cancelFrame: (() => void) | undefined; + const run = (): void => { + if (!active) return; + active = false; + this.cancelScheduledUpdate = undefined; + try { + cancelFrame?.(); + } catch { + // A completed frame remains authoritative when scheduler cleanup fails. + } this.scheduled = false; this.update(); - }); + }; + try { + cancelFrame = this.scheduleFrame(run); + if (typeof cancelFrame !== 'function') { + throw new TypeError('Invalid badge frame scheduler'); + } + if (active) { + this.cancelScheduledUpdate = (): void => { + if (!active) return; + active = false; + this.scheduled = false; + cancelFrame?.(); + }; + } else { + cancelFrame(); + } + } catch { + active = false; + this.cancelScheduledUpdate = undefined; + this.scheduled = false; + } }; private refreshSlots(): void { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts index 1a2cc048f..5ba799f9f 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts @@ -16,7 +16,7 @@ type BindingWindow = Window & { interface BindingOptions { document?: Document | undefined; window?: BindingWindow | undefined; - scheduleFrame?: ((callback: () => void) => void) | undefined; + scheduleFrame?: ((callback: () => void) => () => void) | undefined; } export interface GptDiagnosticsBindingView { @@ -27,12 +27,18 @@ export interface GptDiagnosticsBindingView { type BindingListener = () => void; -function defaultScheduleFrame(callback: () => void): void { - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => callback()); - } else { - queueMicrotask(callback); +function defaultScheduleFrame(callback: () => void): () => void { + if (typeof requestAnimationFrame === 'function' && typeof cancelAnimationFrame === 'function') { + const frame = requestAnimationFrame(() => callback()); + return () => cancelAnimationFrame(frame); } + let active = true; + queueMicrotask(() => { + if (active) callback(); + }); + return () => { + active = false; + }; } function isVisibleInViewport(element: HTMLElement, window: BindingWindow): boolean { @@ -84,12 +90,13 @@ export class GptDiagnosticsBindingManager { private readonly store: BindingStore; private readonly document: Document; private readonly window: BindingWindow; - private readonly scheduleFrame: (callback: () => void) => void; + private readonly scheduleFrame: (callback: () => void) => () => void; private readonly bindings = new Map(); private readonly listeners = new Set(); private readonly slotElementIds = new Set(); private readonly unsubscribeStore: () => void; private mutationObserver?: MutationObserver; + private cancelScheduledRefresh: (() => void) | undefined; private refreshScheduled = false; private destroyed = false; @@ -155,6 +162,14 @@ export class GptDiagnosticsBindingManager { destroy(): void { if (this.destroyed) return; this.destroyed = true; + const cancelRefresh = this.cancelScheduledRefresh; + this.cancelScheduledRefresh = undefined; + this.refreshScheduled = false; + try { + cancelRefresh?.(); + } catch { + // Continue releasing every independently owned binding resource. + } this.unsubscribeStore(); this.mutationObserver?.disconnect(); this.window.removeEventListener('scroll', this.scheduleRefresh); @@ -166,10 +181,40 @@ export class GptDiagnosticsBindingManager { private readonly scheduleRefresh = (): void => { if (this.destroyed || this.refreshScheduled) return; this.refreshScheduled = true; - this.scheduleFrame(() => { + let active = true; + let cancelFrame: (() => void) | undefined; + const run = (): void => { + if (!active) return; + active = false; + this.cancelScheduledRefresh = undefined; + try { + cancelFrame?.(); + } catch { + // A completed frame remains authoritative when scheduler cleanup fails. + } this.refreshScheduled = false; this.refresh(); - }); + }; + try { + cancelFrame = this.scheduleFrame(run); + if (typeof cancelFrame !== 'function') { + throw new TypeError('Invalid binding frame scheduler'); + } + if (active) { + this.cancelScheduledRefresh = (): void => { + if (!active) return; + active = false; + this.refreshScheduled = false; + cancelFrame?.(); + }; + } else { + cancelFrame(); + } + } catch { + active = false; + this.cancelScheduledRefresh = undefined; + this.refreshScheduled = false; + } }; private resolveBinding( diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts index 22614d74c..15594e3fa 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts @@ -25,7 +25,7 @@ type OverlayWindow = Window & { interface OverlayOptions { window?: OverlayWindow | undefined; document?: Document | undefined; - scheduleFrame?: ((callback: () => void) => void) | undefined; + scheduleFrame?: ((callback: () => void) => () => void) | undefined; onExport?: (() => void) | undefined; onShadowRoot?: ((root: ShadowRoot) => void) | undefined; onBadgeLayerChange?: ((layer: HTMLElement | undefined) => void) | undefined; @@ -98,12 +98,18 @@ const PANEL_STYLES = ` } `; -function defaultScheduleFrame(callback: () => void): void { - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => callback()); - } else { - queueMicrotask(callback); +function defaultScheduleFrame(callback: () => void): () => void { + if (typeof requestAnimationFrame === 'function' && typeof cancelAnimationFrame === 'function') { + const frame = requestAnimationFrame(() => callback()); + return () => cancelAnimationFrame(frame); } + let active = true; + queueMicrotask(() => { + if (active) callback(); + }); + return () => { + active = false; + }; } function latestCycle( @@ -189,7 +195,7 @@ export class GptDiagnosticsOverlay { private readonly bindings: OverlayBindings; private readonly window: OverlayWindow; private readonly document: Document; - private readonly scheduleFrame: (callback: () => void) => void; + private readonly scheduleFrame: (callback: () => void) => () => void; private readonly onExport: () => void; private readonly onShadowRoot: ((root: ShadowRoot) => void) | undefined; private readonly onBadgeLayerChange: ((layer: HTMLElement | undefined) => void) | undefined; @@ -198,6 +204,7 @@ export class GptDiagnosticsOverlay { private host: HTMLElement | undefined; private panel: HTMLElement | undefined; private lifecycleObserver?: MutationObserver; + private readonly cancelScheduledFrames = new Set<() => void>(); private visualReady = false; private mountWaitStarted = false; private renderScheduled = false; @@ -240,6 +247,14 @@ export class GptDiagnosticsOverlay { if (this.destroyed) return; this.destroyed = true; this.dismissed = true; + for (const cancelFrame of [...this.cancelScheduledFrames]) { + this.cancelScheduledFrames.delete(cancelFrame); + try { + cancelFrame(); + } catch { + // Continue releasing every independently owned overlay resource. + } + } this.unsubscribeStore(); this.unsubscribeBindings(); this.lifecycleObserver?.disconnect(); @@ -262,8 +277,8 @@ export class GptDiagnosticsOverlay { this.mountWaitStarted = true; this.document.removeEventListener('readystatechange', this.handleReadyStateChange); - this.scheduleFrame(() => { - this.scheduleFrame(() => { + this.scheduleOwnedFrame(() => { + this.scheduleOwnedFrame(() => { this.visualReady = true; if (!this.dismissed) this.mount(); }); @@ -331,10 +346,14 @@ export class GptDiagnosticsOverlay { this.hostCollision = false; } this.remountScheduled = true; - this.scheduleFrame(() => { + if ( + !this.scheduleOwnedFrame(() => { + this.remountScheduled = false; + this.mount(); + }) + ) { this.remountScheduled = false; - this.mount(); - }); + } }); this.lifecycleObserver.observe(this.document.documentElement, { childList: true, @@ -345,10 +364,50 @@ export class GptDiagnosticsOverlay { private scheduleRender(): void { if (this.destroyed || this.renderScheduled) return; this.renderScheduled = true; - this.scheduleFrame(() => { + if ( + !this.scheduleOwnedFrame(() => { + this.renderScheduled = false; + this.render(); + }) + ) { this.renderScheduled = false; - this.render(); - }); + } + } + + private scheduleOwnedFrame(callback: () => void): boolean { + if (this.destroyed) return false; + let active = true; + let cancelFrame: (() => void) | undefined; + let release: (() => void) | undefined; + const run = (): void => { + if (!active) return; + active = false; + if (release) this.cancelScheduledFrames.delete(release); + try { + cancelFrame?.(); + } catch { + // A completed frame remains authoritative when scheduler cleanup fails. + } + if (!this.destroyed) callback(); + }; + try { + cancelFrame = this.scheduleFrame(run); + if (typeof cancelFrame !== 'function') { + throw new TypeError('Invalid overlay frame scheduler'); + } + release = (): void => { + if (!active) return; + active = false; + cancelFrame?.(); + }; + if (active) this.cancelScheduledFrames.add(release); + else cancelFrame(); + return true; + } catch { + active = false; + if (release) this.cancelScheduledFrames.delete(release); + return false; + } } private render(): void { 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 712669973..758dfb739 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 @@ -64,6 +64,16 @@ function runFrame(frames: Array<() => void>): void { frame(); } +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 +115,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); @@ -181,7 +191,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); @@ -218,7 +228,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); @@ -257,7 +267,7 @@ describe('GptDiagnosticsBadgeManager', () => { MutationObserver: undefined, ResizeObserver: undefined, }), - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); expect(() => { @@ -266,4 +276,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 67b57b77d..66ce259f7 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 } @@ -258,7 +268,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 +294,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 +311,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/overlay.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts index 8af37ea93..c0f2f1878 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 @@ -59,6 +59,16 @@ function runNextFrame(frames: Array<() => void>): void { frame(); } +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'); @@ -76,7 +86,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; }, @@ -140,7 +150,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; @@ -215,7 +225,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); @@ -244,7 +254,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; }, @@ -270,7 +280,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; }, @@ -304,4 +314,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(); + }); }); From 7469a510cacc561405bb0952927348077a046f4a Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:55:12 -0700 Subject: [PATCH 153/194] Clean diagnostics directives through server transport --- .../trusted-server-core/src/html_processor.rs | 22 ++- .../src/integrations/gpt_diagnostics.rs | 26 +++ .../integrations/gpt_diagnostics_bootstrap.js | 40 +++- crates/trusted-server-core/src/publisher.rs | 187 ++++++++++++++++++ .../gpt_diagnostics/bootstrap.test.ts | 59 +++++- 5 files changed, 324 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 6728f9364..9047a7db3 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -333,6 +333,15 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso move |el| { if !injected_tsjs.get() { let mut snippet = String::new(); + // The server has already interpreted and removed the reserved + // directive. Its external cleanup asset 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); + } // 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); @@ -867,6 +876,7 @@ mod tests { let processed = String::from_utf8(output).expect("should produce valid UTF-8"); let bundle_marker = "id=\"trustedserver-js\""; let diagnostics_marker = "tsjs-gpt_diagnostics.min.js"; + let cleanup_marker = "tsjs-gpt_diagnostics-bootstrap.min.js"; assert_eq!( processed.matches("__tsjs_gpt_diagnostics_active").count(), @@ -883,6 +893,10 @@ mod tests { 1, "should inject one standalone diagnostics module" ); + assert_eq!(processed.matches(cleanup_marker).count(), 1); + let cleanup_index = processed + .find(cleanup_marker) + .expect("should include the request-scoped cleanup asset"); let bundle_index = processed .find(bundle_marker) .expect("should include immediate TSJS bundle"); @@ -890,8 +904,12 @@ mod tests { .find(diagnostics_marker) .expect("should include standalone diagnostics module"); assert!( - bundle_index < diagnostics_index, - "should load diagnostics after core" + cleanup_index < bundle_index && bundle_index < diagnostics_index, + "cleanup must be an external CSP-compatible script before publisher/core work" + ); + assert!( + !processed.contains("", + GPT_DIAGNOSTICS_BOOTSTRAP_FILENAME + ) + }) + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -174,6 +190,7 @@ pub fn prepare_request( let mut decision = GptDiagnosticsRequestDecision { reserved_directive: had_reserved_query, + cleanup_browser_url: eligible_navigation && had_reserved_query, ..GptDiagnosticsRequestDecision::default() }; if integration_enabled && eligible_navigation && had_reserved_query { @@ -426,6 +443,14 @@ mod tests { ); assert_eq!(request.headers()[header::COOKIE], "other=value"); assert_eq!(decision.boot_config_json(), r#"{"active":true}"#); + assert_eq!( + decision.url_cleanup_script_tag(), + Some( + "" + .to_owned() + ), + "a server-consumed directive should authorize one external cleanup asset" + ); } #[test] @@ -453,6 +478,7 @@ mod tests { ); let decision = prepare_request(&settings(true), &mut active).expect("should prepare"); assert!(decision.active()); + assert_eq!(decision.url_cleanup_script_tag(), None); assert_eq!(active.headers()[header::COOKIE], "other=value"); let mut duplicate = navigation( diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js index dde2197f8..3cbb36163 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js @@ -1,4 +1,36 @@ -// GPT diagnostics activation is server-owned and is transported only through -// the validated, frozen diagnostics boot value. This intentionally has no -// browser-side activation behavior and remains only until the wiring cutover -// removes the superseded asset. +// Request-scoped URL cleanup only. The server injects this asset exactly when +// it has already consumed and stripped at least one reserved directive. +(function () { + "use strict"; + + try { + var href = String(location.href); + var hashIndex = href.indexOf("#"); + var hash = hashIndex < 0 ? "" : href.slice(hashIndex); + var beforeHash = hashIndex < 0 ? href : href.slice(0, hashIndex); + var queryIndex = beforeHash.indexOf("?"); + if (queryIndex < 0) return; + + var pairs = beforeHash.slice(queryIndex + 1).split("&"); + var retained = []; + var removed = false; + for (var index = 0; index < pairs.length; index += 1) { + var pair = pairs[index]; + var equalsIndex = pair.indexOf("="); + var name = equalsIndex < 0 ? pair : pair.slice(0, equalsIndex); + if (name === "ts_console") { + removed = true; + } else { + retained.push(pair); + } + } + if (!removed) return; + + var cleanHref = beforeHash.slice(0, queryIndex); + var retainedQuery = retained.join("&"); + if (retainedQuery !== "") cleanHref += "?" + retainedQuery; + history.replaceState(history.state, "", cleanHref + hash); + } catch (_) { + // Browser-visible cleanup cannot affect diagnostics or publisher code. + } +})(); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4595166dd..c3ebfb314 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -306,6 +306,18 @@ pub fn handle_tsjs_dynamic( } let filename = &path[PREFIX.len()..]; + if filename == crate::integrations::gpt_diagnostics::GPT_DIAGNOSTICS_BOOTSTRAP_FILENAME { + let mut response = serve_static_with_etag( + crate::integrations::gpt_diagnostics::GPT_DIAGNOSTICS_BOOTSTRAP_SOURCE, + req, + "application/javascript; charset=utf-8", + ); + response + .headers_mut() + .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); + return Ok(response); + } + if UNIFIED_FILENAMES.contains(&filename) { // Serve core + immediate modules (excludes deferred like prebid) let module_ids = integration_registry.js_module_ids_immediate(); @@ -5313,6 +5325,73 @@ 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"); + 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}; @@ -6098,6 +6177,96 @@ mod tests { 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("tsjs-gpt_diagnostics.min.js")); + assert_eq!( + body.matches("tsjs-gpt_diagnostics-bootstrap.min.js") + .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("tsjs-gpt_diagnostics.min.js")); + assert!(!active_body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); + + 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("tsjs-gpt_diagnostics.min.js")); + assert_eq!( + disabled_body + .matches("tsjs-gpt_diagnostics-bootstrap.min.js") + .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("tsjs-gpt_diagnostics.min.js")); + assert!(!body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); + } + } + #[tokio::test] async fn publisher_origin_fetch_leaves_stream_response_disabled_when_unsupported() { let settings = create_test_settings(); @@ -7112,6 +7281,24 @@ mod tests { ); } + #[test] + fn ts_console_dynamic_serves_the_non_authoritative_cleanup_asset() { + 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"); + let source = response_body_string(response); + + assert!(source.contains("history.replaceState")); + assert!(source.contains("ts_console")); + assert!(!source.contains("sessionStorage")); + assert!(!source.contains("__tsjs_gpt_diagnostics_active")); + } + #[test] fn parse_single_module_filename_extracts_known_id() { assert_eq!( diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts index c40af3e56..741b70459 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; const bootstrapPath = resolve( process.cwd(), @@ -9,10 +9,61 @@ const bootstrapPath = resolve( ); const bootstrapSource = readFileSync(bootstrapPath, 'utf8'); describe('GPT diagnostics activation ownership', () => { - it('leaves no browser-owned query, storage, history, or activation-flag bootstrap', () => { - expect(bootstrapSource).not.toMatch(/ts_console/); + it('only performs one server-authorized raw URL cleanup without publishing authority', () => { + const replaceState = vi.fn(); + const state = Object.freeze({ publisher: 'state' }); + const location = Object.freeze({ + href: 'https://publisher.example/a%2Fb?keep=%2F&ts_console=1&space=a+b&ts_console=bogus#frag%20x', + }); + const history = Object.freeze({ replaceState, state }); + + Function('location', 'history', bootstrapSource)(location, history); + + expect(replaceState).toHaveBeenCalledExactlyOnceWith( + state, + '', + 'https://publisher.example/a%2Fb?keep=%2F&space=a+b#frag%20x' + ); expect(bootstrapSource).not.toMatch(/sessionStorage|localStorage/); - expect(bootstrapSource).not.toMatch(/replaceState/); expect(bootstrapSource).not.toMatch(/__tsjs_gpt_diagnostics_active/); + expect(bootstrapSource).not.toMatch(/window\s*\[/); + }); + + it('contains replaceState failure and preserves an unrelated URL without a call', () => { + const replaceState = vi.fn(() => { + throw new Error('fictional history failure'); + }); + expect(() => + Function('location', 'history', bootstrapSource)( + Object.freeze({ href: 'https://publisher.example/?ts_console=false#kept' }), + Object.freeze({ replaceState, state: null }) + ) + ).not.toThrow(); + expect(replaceState).toHaveBeenCalledOnce(); + + replaceState.mockClear(); + Function('location', 'history', bootstrapSource)( + Object.freeze({ href: 'https://publisher.example/?contest_console=1#kept' }), + Object.freeze({ replaceState, state: null }) + ); + expect(replaceState).not.toHaveBeenCalled(); + }); + + it.each([ + ['https://publisher.example/a?ts_console=1', 'https://publisher.example/a'], + ['https://publisher.example/a?ts_console=1&', 'https://publisher.example/a'], + [ + 'https://publisher.example/a?&ts_console=1&keep=%2F', + 'https://publisher.example/a?&keep=%2F', + ], + ])('matches the server sanitizer for empty raw query segments', (href, expected) => { + const replaceState = vi.fn(); + + Function('location', 'history', bootstrapSource)( + Object.freeze({ href }), + Object.freeze({ replaceState, state: null }) + ); + + expect(replaceState).toHaveBeenCalledExactlyOnceWith(null, '', expected); }); }); From e2b29001fcae735555479a74cb07e25408b4755c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:01:06 -0700 Subject: [PATCH 154/194] Enrich render trace from safe GPT facts --- .../trusted-server-js/lib/src/core/trace.ts | 121 +++++++++++++++++- .../lib/test/core/trace_runtime.test.ts | 105 +++++++++++++++ 2 files changed, 225 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 836c7408f..934a4e32e 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -562,6 +562,27 @@ const MAX_RENDER_TRACE_NOTIFICATIONS = 200; 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: object; readonly elementId?: string }>; + readonly isEmpty?: boolean; + readonly inViewPercentage?: number; +} + +/** Current registered-slot identity and presentation state for one safe GPT fact. */ +export interface RenderTraceGptResolutionV1 { + readonly slotId: string; + readonly elementId?: string; + readonly visible?: boolean; +} + export interface RenderTraceRuntimeScheduler { readonly set: (callback: () => void, milliseconds: number) => unknown; readonly clear: (handle: unknown) => void; @@ -588,6 +609,10 @@ export interface RenderTraceRuntimeOwner { patch: RenderTraceUpdateV1 ) => Readonly | undefined; readonly prune: (slotId: string, sequence?: number) => boolean; + readonly observeGptFact: ( + fact: Readonly, + resolve: (elementId: string | undefined) => RenderTraceGptResolutionV1 | undefined + ) => void; readonly dispose: () => void; } @@ -935,6 +960,10 @@ export function createRenderTraceDiagnostics( const counts = new Map(); const history: Array> = []; const recordsBySequence = new Map>(); + const gptImpressions = new Map< + object, + { readonly baselineSequence: number | undefined; sequence?: number; readonly slotId: string } + >(); const subscribers = new Map(); const pendingOrder: number[] = []; const pendingBySequence = new Map(); @@ -1125,6 +1154,87 @@ export function createRenderTraceDiagnostics( return true; }; + const observeGptFact = ( + fact: Readonly, + resolve: (elementId: string | undefined) => RenderTraceGptResolutionV1 | undefined + ): void => { + if (disposed || typeof resolve !== 'function') return; + try { + const token = fact.slot.token; + if (typeof token !== 'object' || token === null || !Object.isFrozen(token)) return; + const resolution = resolve(fact.slot.elementId); + if (!resolution || typeof resolution.slotId !== 'string' || resolution.slotId === '') return; + + if (fact.kind === 'slotRequested') { + for (const [candidateToken, impression] of gptImpressions) { + if (impression.slotId === resolution.slotId) gptImpressions.delete(candidateToken); + } + if (gptImpressions.size >= MAX_RENDER_TRACE_SLOTS) { + const oldestToken = gptImpressions.keys().next().value as object | undefined; + if (oldestToken) gptImpressions.delete(oldestToken); + } + gptImpressions.set(token, { + baselineSequence: current.get(resolution.slotId)?.seq, + slotId: resolution.slotId, + }); + return; + } + + const impression = gptImpressions.get(token); + if (!impression || impression.slotId !== resolution.slotId) return; + if (fact.kind === 'slotResponseReceived') return; + if (fact.kind === 'slotRenderEnded') { + if (typeof fact.isEmpty !== 'boolean') 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', + }); + 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.sequence = enriched?.seq ?? target.seq; + return; + } + + const targetSequence = impression.sequence; + if (targetSequence === undefined || current.get(impression.slotId)?.seq !== targetSequence) { + return; + } + if (fact.kind === 'impressionViewable') { + enrich(targetSequence, { visible: true }); + } else if ( + fact.kind === 'slotVisibilityChanged' && + typeof fact.inViewPercentage === 'number' && + Number.isFinite(fact.inViewPercentage) + ) { + enrich(targetSequence, { visible: fact.inViewPercentage > 0 }); + } else if (fact.kind === 'slotOnload' && resolution.visible !== undefined) { + enrich(targetSequence, { visible: resolution.visible }); + } + } catch { + // GPT diagnostics cannot affect the committed render or adapter callback. + } + }; + const api: RenderTraceDiagnostics = Object.freeze({ current: (): Readonly>> => { const snapshot = Object.create(null) as Record>; @@ -1175,10 +1285,19 @@ export function createRenderTraceDiagnostics( history.length = 0; recordsBySequence.clear(); counts.clear(); + gptImpressions.clear(); presentation.dispose(); }; - return Object.freeze({ api, diagnostics: api, record, enrich, prune, dispose }); + return Object.freeze({ + api, + diagnostics: api, + record, + enrich, + prune, + observeGptFact, + dispose, + }); } /** Short name used by the browser composition owner. */ 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 index ef63e0065..bcdabc5c5 100644 --- a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -188,6 +188,111 @@ describe('render trace diagnostics runtime', () => { expect(owner.diagnostics.history()).toHaveLength(1); }); + it('records an unattributed GPT request as one GAM-refresh impression', () => { + const { owner } = harness(); + const token = Object.freeze(Object.create(null) as object); + const resolve = () => + Object.freeze({ slotId: 'publisher-slot', elementId: 'publisher-slot', visible: true }); + + owner.observeGptFact( + Object.freeze({ + kind: 'slotRequested', + observedAtMs: 1, + slot: Object.freeze({ token, elementId: 'publisher-slot' }), + }), + resolve + ); + owner.observeGptFact( + Object.freeze({ + kind: 'slotRenderEnded', + observedAtMs: 2, + slot: Object.freeze({ token, 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('enriches only the same GPT impression without weakening TS placement truth', () => { + const { owner } = harness(); + const token = Object.freeze(Object.create(null) as object); + const slot = Object.freeze({ token, elementId: 'ts-slot' }); + const resolve = () => Object.freeze({ slotId: 'ts-slot', elementId: 'ts-slot', 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: true, + servedFrom: 'pbs-cache', + }) + ); + }); + + it('routes all GPT lifecycle facts and scopes visibility to the active physical request', () => { + const { owner } = harness(); + const firstToken = Object.freeze(Object.create(null) as object); + const secondToken = Object.freeze(Object.create(null) as object); + const resolve = () => Object.freeze({ slotId: 'visible-slot', visible: false }); + const fact = ( + kind: string, + token: object, + fields: Readonly> = Object.freeze({}) + ) => Object.freeze({ kind, observedAtMs: 1, slot: Object.freeze({ token }), ...fields }); + + owner.observeGptFact(fact('slotRequested', firstToken), resolve); + owner.observeGptFact(fact('slotResponseReceived', firstToken), resolve); + owner.observeGptFact(fact('slotRenderEnded', firstToken, { isEmpty: false }), resolve); + owner.observeGptFact(fact('slotOnload', firstToken), resolve); + owner.observeGptFact(fact('impressionViewable', firstToken), resolve); + expect(owner.diagnostics.current()['visible-slot']?.visible).toBe(true); + owner.observeGptFact( + fact('slotVisibilityChanged', firstToken, { inViewPercentage: 0 }), + resolve + ); + expect(owner.diagnostics.current()['visible-slot']?.visible).toBe(false); + + owner.observeGptFact(fact('slotRequested', secondToken), resolve); + owner.observeGptFact(fact('impressionViewable', secondToken), 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); + }); + it('drops the oldest of 201 pending records and cancels work on disposal', () => { const { owner, tasks, drain } = harness(); const listener = vi.fn(); From 3ba07ff523311aa143f1ea5ef754457eb1d3c625 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:03:01 -0700 Subject: [PATCH 155/194] Latch first GPT render trace terminal fact --- crates/trusted-server-js/lib/src/core/trace.ts | 9 ++++++++- .../lib/test/core/trace_runtime.test.ts | 15 +++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 934a4e32e..00daa12c2 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -962,7 +962,12 @@ export function createRenderTraceDiagnostics( const recordsBySequence = new Map>(); const gptImpressions = new Map< object, - { readonly baselineSequence: number | undefined; sequence?: number; readonly slotId: string } + { + readonly baselineSequence: number | undefined; + renderEnded?: boolean; + sequence?: number; + readonly slotId: string; + } >(); const subscribers = new Map(); const pendingOrder: number[] = []; @@ -1185,6 +1190,8 @@ export function createRenderTraceDiagnostics( if (fact.kind === 'slotResponseReceived') return; if (fact.kind === 'slotRenderEnded') { if (typeof fact.isEmpty !== 'boolean') return; + if (impression.renderEnded) return; + impression.renderEnded = true; const latest = current.get(impression.slotId); const target = latest && latest.seq !== impression.baselineSequence 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 index bcdabc5c5..abded6af1 100644 --- a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -5,6 +5,7 @@ import { DiagnosticsSubscriberLimitError, TRACE_BADGE_CLASS, TRACE_PANEL_ID, + type RenderTraceGptFactV1, } from '../../src/core/trace'; function harness() { @@ -256,7 +257,7 @@ describe('render trace diagnostics runtime', () => { path: 'ssat', rendered: true, injected: true, - gamEmpty: true, + gamEmpty: false, servedFrom: 'pbs-cache', }) ); @@ -268,10 +269,13 @@ describe('render trace diagnostics runtime', () => { const secondToken = Object.freeze(Object.create(null) as object); const resolve = () => Object.freeze({ slotId: 'visible-slot', visible: false }); const fact = ( - kind: string, + kind: RenderTraceGptFactV1['kind'], token: object, - fields: Readonly> = Object.freeze({}) - ) => Object.freeze({ kind, observedAtMs: 1, slot: Object.freeze({ token }), ...fields }); + fields: Readonly> = Object.freeze( + {} + ) + ): Readonly => + Object.freeze({ kind, observedAtMs: 1, slot: Object.freeze({ token }), ...fields }); owner.observeGptFact(fact('slotRequested', firstToken), resolve); owner.observeGptFact(fact('slotResponseReceived', firstToken), resolve); @@ -286,11 +290,14 @@ describe('render trace diagnostics runtime', () => { expect(owner.diagnostics.current()['visible-slot']?.visible).toBe(false); owner.observeGptFact(fact('slotRequested', secondToken), resolve); + owner.observeGptFact(fact('slotRenderEnded', firstToken, { isEmpty: true }), resolve); + owner.observeGptFact(fact('impressionViewable', firstToken), resolve); owner.observeGptFact(fact('impressionViewable', secondToken), 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('drops the oldest of 201 pending records and cancels work on disposal', () => { From 0e30fd3990318069c3c75f4f92ae1d1fa98efb82 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:04:16 -0700 Subject: [PATCH 156/194] Close the maximal runtime ownership gate --- .../scripts/integration-inventory-v1.d.mts | 1 + .../lib/scripts/integration-inventory-v1.mjs | 14 + .../lib/src/shared/beacon_guard.ts | 54 ++-- .../lib/test/build/release-v1.test.mjs | 27 ++ .../test/composition/maximal-runtime.test.ts | 239 ++++++++++++++++++ .../lib/test/shared/beacon_guard.test.ts | 14 + 6 files changed, 334 insertions(+), 15 deletions(-) create mode 100644 crates/trusted-server-js/lib/scripts/integration-inventory-v1.d.mts create mode 100644 crates/trusted-server-js/lib/scripts/integration-inventory-v1.mjs create mode 100644 crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts 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/src/shared/beacon_guard.ts b/crates/trusted-server-js/lib/src/shared/beacon_guard.ts index 94618dc30..d7d4273e7 100644 --- a/crates/trusted-server-js/lib/src/shared/beacon_guard.ts +++ b/crates/trusted-server-js/lib/src/shared/beacon_guard.ts @@ -55,8 +55,12 @@ function extractUrl(input: RequestInfo | URL): string | null { */ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { let installed = false; - let originalSendBeacon: typeof navigator.sendBeacon | null = null; - let originalFetch: typeof window.fetch | null = null; + let originalSendBeacon: typeof navigator.sendBeacon | undefined; + let originalSendBeaconDescriptor: PropertyDescriptor | undefined; + let originalFetch: typeof window.fetch | undefined; + let originalFetchDescriptor: PropertyDescriptor | undefined; + let sendBeaconPatched = false; + let fetchPatched = false; const prefix = `${config.name} beacon guard`; function install(): void { @@ -74,23 +78,31 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { // --- Patch navigator.sendBeacon --- if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { - originalSendBeacon = navigator.sendBeacon.bind(navigator); + originalSendBeacon = navigator.sendBeacon; + originalSendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + sendBeaconPatched = true; navigator.sendBeacon = function (url: string, data?: BodyInit | null): boolean { + const sendBeacon = originalSendBeacon; + if (!sendBeacon) return false; if (config.isTargetUrl(url)) { const rewritten = config.rewriteUrl(url); log.info(`${prefix}: rewriting sendBeacon`, { original: url, rewritten }); - return originalSendBeacon!(rewritten, data); + return Reflect.apply(sendBeacon, navigator, [rewritten, data]); } - return originalSendBeacon!(url, data); + return Reflect.apply(sendBeacon, navigator, [url, data]); }; } // --- Patch window.fetch --- if (typeof window.fetch === 'function') { - originalFetch = window.fetch.bind(window); + originalFetch = window.fetch; + originalFetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + fetchPatched = true; window.fetch = function (input: RequestInfo | URL, init?: RequestInit): Promise { + const fetch = originalFetch; + if (!fetch) return Promise.reject(new TypeError('fetch is unavailable')); const url = extractUrl(input); if (url && config.isTargetUrl(url)) { @@ -100,12 +112,12 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { // If the input was a Request, create a new one with the rewritten URL if (input instanceof Request) { const newRequest = new Request(rewritten, input); - return originalFetch!(newRequest, init); + return Reflect.apply(fetch, window, [newRequest, init]); } - return originalFetch!(rewritten, init); + return Reflect.apply(fetch, window, [rewritten, init]); } - return originalFetch!(input, init); + return Reflect.apply(fetch, window, [input, init]); }; } @@ -118,14 +130,26 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { } function reset(): void { - if (originalSendBeacon && typeof navigator !== 'undefined') { - navigator.sendBeacon = originalSendBeacon; - originalSendBeacon = null; + if (sendBeaconPatched && typeof navigator !== 'undefined') { + if (originalSendBeaconDescriptor) { + Object.defineProperty(navigator, 'sendBeacon', originalSendBeaconDescriptor); + } else { + Reflect.deleteProperty(navigator, 'sendBeacon'); + } } - if (originalFetch && typeof window !== 'undefined') { - window.fetch = originalFetch; - originalFetch = null; + if (fetchPatched && typeof window !== 'undefined') { + if (originalFetchDescriptor) { + Object.defineProperty(window, 'fetch', originalFetchDescriptor); + } else { + Reflect.deleteProperty(window, 'fetch'); + } } + originalSendBeacon = undefined; + originalSendBeaconDescriptor = undefined; + originalFetch = undefined; + originalFetchDescriptor = undefined; + sendBeaconPatched = false; + fetchPatched = false; installed = false; log.debug(`${prefix}: reset and uninstalled`); } 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 index 752766679..6d19c236a 100644 --- a/crates/trusted-server-js/lib/test/build/release-v1.test.mjs +++ b/crates/trusted-server-js/lib/test/build/release-v1.test.mjs @@ -16,6 +16,33 @@ const libDirectory = path.resolve(testDirectory, '../..'); const repositoryRoot = path.resolve(libDirectory, '../../..'); const bundle = (id, logical) => ({ id, bytes: Buffer.from(`${logical}${RELEASE_SENTINEL}`) }); +const EXPECTED_RELEASE_BUNDLE_ORDER = [ + 'core', + 'creative', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'gpt_diagnostics', + 'lockr', + 'osano', + 'permutive', + 'prebid', + 'sourcepoint', + 'testlight', +]; + +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.bundles.map(({ id }) => id), + EXPECTED_RELEASE_BUNDLE_ORDER + ); +}); + 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') 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..3a1f507f5 --- /dev/null +++ b/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts @@ -0,0 +1,239 @@ +import path from 'node:path'; + +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'; +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 { createGptIntegrationRegistration } from '../../src/integrations/gpt/module'; +import { createGptDiagnosticsIntegrationRegistration } from '../../src/integrations/gpt_diagnostics/module'; +import { createLockrIntegrationRegistration } from '../../src/integrations/lockr/module'; +import { createOsanoIntegrationRegistration } from '../../src/integrations/osano/module'; +import { createPermutiveIntegrationRegistration } from '../../src/integrations/permutive/module'; +import { createPrebidIntegrationRegistration } from '../../src/integrations/prebid/module'; +import { createSourcepointIntegrationRegistration } from '../../src/integrations/sourcepoint/module'; +import { createTestlightIntegrationRegistration } from '../../src/integrations/testlight/module'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from '../../src/kernel/integration_registry'; +import { discoverIntegrationModules } from '../../scripts/integration-inventory-v1.mjs'; + +const TEST_RELEASE_ID = 'a'.repeat(64); + +type RegistrationFactory = (release: string) => IntegrationRegistration; + +const REGISTRATION_FACTORIES = new Map([ + ['creative', createCreativeIntegrationRegistration], + ['datadome', createDataDomeIntegrationRegistration], + ['didomi', createDidomiIntegrationRegistration], + ['google_tag_manager', createGoogleTagManagerIntegrationRegistration], + ['gpt', createGptIntegrationRegistration], + ['gpt_diagnostics', createGptDiagnosticsIntegrationRegistration], + ['lockr', createLockrIntegrationRegistration], + ['osano', createOsanoIntegrationRegistration], + ['permutive', createPermutiveIntegrationRegistration], + ['prebid', createPrebidIntegrationRegistration], + ['sourcepoint', createSourcepointIntegrationRegistration], + ['testlight', createTestlightIntegrationRegistration], +]); + +function generatedIntegrationIds(): readonly string[] { + return Object.freeze(discoverIntegrationModules(path.resolve(process.cwd(), 'src/integrations'))); +} + +function tracedRegistration( + registration: IntegrationRegistration, + events: string[] +): IntegrationRegistration { + return Object.freeze({ + id: registration.id, + release: registration.release, + prepare: async (context: IntegrationPrepareContext) => { + events.push(`prepare:${registration.id}`); + const prepared = await registration.prepare(context); + return Object.freeze({ + activate: (activationContext: IntegrationActivationContext): void => { + events.push(`activate:${registration.id}`); + activationContext.onDispose(() => events.push(`dispose:${registration.id}`)); + prepared.activate(activationContext); + }, + }); + }, + }); +} + +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') return Object.freeze({ rewriteSdk: true }); + return undefined; +} + +describe('generated maximal browser runtime transaction', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('owns all server bundles once and disposes them in exact reverse generated order', async () => { + vi.useFakeTimers(); + const integrationIds = generatedIntegrationIds(); + 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); + }); + const activeObservers = new Set(); + const activeMutationObservers = new Set(); + 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 googletag = Object.freeze({ + ...createNoopGoogletagAdapter(), + observeDiagnostics: (observer: GoogletagDiagnosticsObserver) => { + activeObservers.add(observer); + return (): void => { + activeObservers.delete(observer); + }; + }, + }); + 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: { + version: 1, + releaseId: TEST_RELEASE_ID, + integrations: integrationIds.map((id) => ({ id, required: true })), + }, + knownIntegrationIds: integrationIds, + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative: { version: 1, enabled: true, clickGuard: true, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + getBindings: (id) => + Object.freeze({ config: integrationConfig(id), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag, + messaging: Object.freeze({ + ...createNoopMessagingAdapter(), + installCaptureListener: () => { + activeCaptureListeners += 1; + let active = true; + return (): void => { + if (!active) return; + active = false; + activeCaptureListeners -= 1; + }; + }, + }), + prebid: createNoopPrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + expect(composition.runtime.start()).toBe(true); + expect(composition.runtime.start()).toBe(false); + for (const registration of registrations) { + expect(registration.release).toBe(TEST_RELEASE_ID); + expect(composition.runtime.registerIntegration(registration)).toBe(true); + events.push(`register:${registration.id}`); + } + + const installed = await composition.runtime.install(); + + if (installed.state === 'fallback') { + throw new Error(`${installed.reason}: ${events.join(',')}`); + } + expect(installed).toEqual({ + state: 'kernel', + runtimeFailures: [], + dispose: expect.any(Function), + }); + expect(composition.runtime.state).toBe('kernel'); + expect(target['releaseId']).toBe(TEST_RELEASE_ID); + expect(events.filter((event) => event.startsWith('register:'))).toEqual( + integrationIds.map((id) => `register:${id}`) + ); + expect(events.filter((event) => event.startsWith('prepare:'))).toEqual( + integrationIds.map((id) => `prepare:${id}`) + ); + expect(events.filter((event) => event.startsWith('activate:'))).toEqual( + integrationIds.map((id) => `activate:${id}`) + ); + expect(composition.runtimeSessionForTest()?.interfaces).toMatchObject( + Object.fromEntries(integrationIds.map((id) => [id, expect.any(Object)])) + ); + expect(composition.auctionContextRegistryForTest()?.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: ['permutive'], + }); + expect(activeObservers.size).toBe(1); + expect(activeMutationObservers.size).toBeGreaterThan(0); + expect(activeCaptureListeners).toBe(1); + + window.dispatchEvent(new Event('resize')); + composition.runtime.dispose(); + composition.runtime.dispose(); + await Promise.resolve(); + + expect(events.filter((event) => event.startsWith('dispose:'))).toEqual( + [...integrationIds].reverse().map((id) => `dispose:${id}`) + ); + expect(activeObservers.size).toBe(0); + expect(activeMutationObservers.size).toBe(0); + expect(activeCaptureListeners).toBe(0); + 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); + }); +}); 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 f36a1a500..838c51b77 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 @@ -147,6 +147,20 @@ 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); + }); + describe('multiple guards', () => { it('should allow independent guards to coexist', () => { const config2: BeaconGuardConfig = { From 14ae7ae0b68407cdac79e43cc68572449608f84a Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:10:19 -0700 Subject: [PATCH 157/194] Wire GPT facts into render diagnostics --- .../lib/src/composition/browser.ts | 43 ++++++- .../lib/test/composition/browser.test.ts | 111 +++++++++++++++++- 2 files changed, 146 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 2698c870c..6aa3df2bd 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -30,6 +30,7 @@ import type { import { createRenderTrace, isEffectivelyVisible, + type RenderTraceGptFactV1, type RenderTraceRuntimeOwner, } from '../core/trace'; import { @@ -213,7 +214,7 @@ export interface BrowserCoreActivations { export interface TestBrowserRuntimeCompositionOptions extends BrowserCompositionOptions { readonly auctionFetcherForTest?: AuctionBatchFetcher; - readonly coreActivations: BrowserCoreActivations; + readonly coreActivations?: BrowserCoreActivations; readonly creativeActivationForTest?: (config: Readonly) => () => void; readonly creativeStartupForTest?: (config: Readonly) => void; readonly createIdentityIssuerForTest?: NavigationIdentityIssuerFactory; @@ -413,12 +414,12 @@ export function createNoopBrowserComposition(): BrowserComposition { } /** - * Construct the single runtime only for coordinated-cutover tests. + * Construct the sole browser runtime composition without claiming a global. * - * The shipped core remains on its existing bootstrap until Task 19; keeping this - * explicit prevents an import of the composition module from claiming globals. + * The core entry point owns the one production claim; tests may construct the + * same composition against explicit targets and adapters. */ -export function createTestBrowserRuntimeComposition( +export function createBrowserRuntimeComposition( runtimeOptions: RuntimeOptions, compositionOptions: TestBrowserRuntimeCompositionOptions ): BrowserRuntimeComposition { @@ -442,6 +443,33 @@ export function createTestBrowserRuntimeComposition( 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) 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, + ...(element === undefined + ? {} + : { elementId: element.id, visible: isEffectivelyVisible(element) }), + }); + } + ); + } catch { + // Render tracing never affects an already-committed adapter observation. + } try { gptDiagnosticsFacts?.publish(observation as unknown as Readonly); } catch { @@ -1270,7 +1298,7 @@ export function createTestBrowserRuntimeComposition( }); browserServices.slots.activate(); browserServices.slots.start(); - compositionOptions.coreActivations.correctnessGptListeners( + compositionOptions.coreActivations?.correctnessGptListeners( context, composition.adapters, browserServices @@ -1332,3 +1360,6 @@ export function createTestBrowserRuntimeComposition( }, }); } + +/** Temporary test import alias; production has only one composition implementation. */ +export const createTestBrowserRuntimeComposition = createBrowserRuntimeComposition; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 3352eae5b..4ec11e107 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -5,6 +5,7 @@ import { createNoopGoogletagAdapter, type GoogletagAdapter, type GoogletagBindingStatus, + type GoogletagDiagnosticsFact, type GoogletagDiagnosticsObserver, type GoogletagFacade, type GoogletagPublisherCallObserver, @@ -28,6 +29,7 @@ import { } from '../../src/adapters/prebid'; import { createBrowserComposition, + createBrowserRuntimeComposition, createNoopBrowserComposition, createTestBrowserRuntimeComposition, } from '../../src/composition/browser'; @@ -78,6 +80,7 @@ function synchronousGptAdapter() { const targeting = new WeakMap>(); const bindingToken = Object.freeze({}); const refresh = vi.fn(); + const diagnosticsSlots = new WeakMap(); let diagnosticsObserver: GoogletagDiagnosticsObserver | undefined; let publisherObserver: GoogletagPublisherCallObserver | undefined; const facade: GoogletagFacade = Object.freeze({ @@ -146,11 +149,32 @@ function synchronousGptAdapter() { for (const listener of listeners.get(eventType) ?? []) { listener(event); if (typeof event !== 'object' || event === null || !('slot' in event)) continue; + const physicalSlot = event.slot; + if (typeof physicalSlot !== 'object' || physicalSlot === null) continue; + 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; + safeSlot = Object.freeze({ + token: Object.freeze(Object.create(null) as object), + ...(typeof elementId === 'string' ? { elementId } : {}), + ...(typeof adUnitPath === 'string' ? { adUnitPath } : {}), + }); + diagnosticsSlots.set(physicalSlot, safeSlot); + } diagnosticsObserver?.( Object.freeze({ ...event, kind: eventType, - slot: event.slot, + observedAtMs: 1, + slot: safeSlot, }) as Parameters[0] ); } @@ -1024,7 +1048,11 @@ describe('browser composition', () => { expect(observations).toContainEqual( expect.objectContaining({ kind: 'slotRenderEnded', - slot: observedSlot, + slot: expect.objectContaining({ + elementId: 'bus-slot', + adUnitPath: '/example/bus-slot', + token: expect.any(Object), + }), isEmpty: false, }) ) @@ -1034,6 +1062,85 @@ describe('browser composition', () => { } }); + 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 = createBrowserRuntimeComposition( + { + target, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [{ id: 'gpt_diagnostics', required: true }], + }, + knownIntegrationIds: Object.freeze(['gpt_diagnostics']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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( + 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', + }); + + gpt.emit('slotRequested', { slot: physicalSlot }); + gpt.emit('slotRenderEnded', { slot: physicalSlot, isEmpty: false }); + gpt.emit('impressionViewable', { slot: physicalSlot }); + + 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('injects GPT and Prebid module boundaries with only server-frozen configuration', async () => { const releaseId = 'a'.repeat(64); const target = {}; From 747a6304f2fdb44c56b37bcfd2c3b1b7e2e469d4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:15:00 -0700 Subject: [PATCH 158/194] Test GPT fact identity and timing --- .../lib/test/adapters/googletag.test.ts | 44 ++++++++++++++++++- .../gpt_diagnostics/index.test.ts | 19 ++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index 1aadcdae0..4b57e7c9d 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createBrowserGoogletagAdapter } from '../../src/adapters/googletag'; +import { + createBrowserGoogletagAdapter, + type GoogletagDiagnosticsFact, +} from '../../src/adapters/googletag'; type Command = () => void; @@ -1183,6 +1186,45 @@ describe('browser googletag adapter readiness', () => { expect(ready.pubads.addEventListener).not.toHaveBeenCalled(); }); + it('keeps one non-capability token per physical Slot and never freezes publisher authority', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ + googletag: ready.googletag, + performance: { now: () => 7 }, + }); + const facts: GoogletagDiagnosticsFact[] = []; + adapter.observeDiagnostics?.((fact) => facts.push(fact)); + await adapter.run((gpt) => gpt.subscribe('slotRequested', () => undefined)).result; + const first = { + getSlotElementId: () => 'same-id', + getAdUnitPath: () => '/example/first', + setTargeting: vi.fn(), + }; + const replacement = { + getSlotElementId: () => 'same-id', + getAdUnitPath: () => '/example/replacement', + setTargeting: vi.fn(), + }; + const emit = (slot: object): void => { + for (const listener of ready.listeners.get('slotRequested') ?? []) listener({ slot }); + }; + + emit(first); + emit(first); + emit(replacement); + + expect(facts).toHaveLength(3); + expect(facts[0]?.slot.token).toBe(facts[1]?.slot.token); + expect(facts[2]?.slot.token).not.toBe(facts[0]?.slot.token); + expect(Object.isFrozen(first)).toBe(false); + expect(Object.isFrozen(replacement)).toBe(false); + expect(Reflect.ownKeys(facts[0]?.slot ?? {}).sort()).toEqual([ + 'adUnitPath', + 'elementId', + 'token', + ]); + }); + it('rolls back an exact GPT listener when installation replaces the binding', async () => { const first = createReadyGoogletag(); const replacement = createReadyGoogletag(); 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 b0ed27ca8..4f8c9b10e 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 @@ -101,6 +101,25 @@ describe('GPT diagnostics runtime', () => { release(); }); + 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 }, + }); + release(); + }); + it('releases its consumer so replacement activation receives intervening buffered facts', () => { const buffer = createGptDiagnosticsFactBuffer(); const runtime = createGptDiagnosticsRuntime(buffer, { window, document }); From a1692a0a7cf24aabc2ff21d238e023fc83711b96 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:17:09 -0700 Subject: [PATCH 159/194] Format GPT diagnostics resilience changes --- .../lib/src/adapters/googletag.ts | 9 +++++-- .../src/integrations/gpt_diagnostics/store.ts | 14 +++++----- .../gpt_diagnostics/bootstrap.test.ts | 26 ++++++++++++------- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index fb49c48a3..a540046b8 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -893,7 +893,9 @@ export function createBrowserGoogletagAdapter( const physicalSlot = slot as object; let safeSlot = weakMapValue(diagnosticsSlots, physicalSlot); if (!safeSlot) { - const optionalStringCall = (key: 'getSlotElementId' | 'getAdUnitPath'): string | undefined => { + const optionalStringCall = ( + key: 'getSlotElementId' | 'getAdUnitPath' + ): string | undefined => { const method = safeMember(physicalSlot, key); if (typeof method !== 'function') return undefined; try { @@ -967,7 +969,10 @@ export function createBrowserGoogletagAdapter( let observedAtMs = 0; try { const performance = safeMember(target, 'performance'); - if ((typeof performance === 'object' && performance !== null) || typeof performance === 'function') { + 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, []); diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts index c0cff3276..9183b6e09 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts @@ -431,15 +431,17 @@ export class GptDiagnosticsStore { record.slotElementId ??= (typeof slot.elementId === 'string' && slot.elementId.length > 0 ? slot.elementId - : undefined) ?? optionalNonEmptyString( - typeof slot.getSlotElementId === 'function' ? slot.getSlotElementId.bind(slot) : undefined - ); + : undefined) ?? + optionalNonEmptyString( + typeof slot.getSlotElementId === 'function' ? slot.getSlotElementId.bind(slot) : undefined + ); record.adUnitPath ??= (typeof slot.adUnitPath === 'string' && slot.adUnitPath.length > 0 ? slot.adUnitPath - : undefined) ?? optionalNonEmptyString( - typeof slot.getAdUnitPath === 'function' ? slot.getAdUnitPath.bind(slot) : undefined - ); + : undefined) ?? + optionalNonEmptyString( + typeof slot.getAdUnitPath === 'function' ? slot.getAdUnitPath.bind(slot) : undefined + ); } private markRecentlyActive(runtimeSlotNumber: number): void { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts index 741b70459..db827cbb5 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts @@ -34,7 +34,11 @@ describe('GPT diagnostics activation ownership', () => { throw new Error('fictional history failure'); }); expect(() => - Function('location', 'history', bootstrapSource)( + Function( + 'location', + 'history', + bootstrapSource + )( Object.freeze({ href: 'https://publisher.example/?ts_console=false#kept' }), Object.freeze({ replaceState, state: null }) ) @@ -42,7 +46,11 @@ describe('GPT diagnostics activation ownership', () => { expect(replaceState).toHaveBeenCalledOnce(); replaceState.mockClear(); - Function('location', 'history', bootstrapSource)( + Function( + 'location', + 'history', + bootstrapSource + )( Object.freeze({ href: 'https://publisher.example/?contest_console=1#kept' }), Object.freeze({ replaceState, state: null }) ); @@ -52,17 +60,15 @@ describe('GPT diagnostics activation ownership', () => { it.each([ ['https://publisher.example/a?ts_console=1', 'https://publisher.example/a'], ['https://publisher.example/a?ts_console=1&', 'https://publisher.example/a'], - [ - 'https://publisher.example/a?&ts_console=1&keep=%2F', - 'https://publisher.example/a?&keep=%2F', - ], + ['https://publisher.example/a?&ts_console=1&keep=%2F', 'https://publisher.example/a?&keep=%2F'], ])('matches the server sanitizer for empty raw query segments', (href, expected) => { const replaceState = vi.fn(); - Function('location', 'history', bootstrapSource)( - Object.freeze({ href }), - Object.freeze({ replaceState, state: null }) - ); + Function( + 'location', + 'history', + bootstrapSource + )(Object.freeze({ href }), Object.freeze({ replaceState, state: null })); expect(replaceState).toHaveBeenCalledExactlyOnceWith(null, '', expected); }); From b79b4fc511ce1cdd222e956a43c3d5357ca8f1a2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:20:58 -0700 Subject: [PATCH 160/194] Preserve publisher beacon replacements --- .../lib/src/shared/beacon_guard.ts | 71 +++++-- .../lib/test/shared/beacon_guard.test.ts | 173 +++++++++++++++++- 2 files changed, 225 insertions(+), 19 deletions(-) diff --git a/crates/trusted-server-js/lib/src/shared/beacon_guard.ts b/crates/trusted-server-js/lib/src/shared/beacon_guard.ts index d7d4273e7..a5a99552f 100644 --- a/crates/trusted-server-js/lib/src/shared/beacon_guard.ts +++ b/crates/trusted-server-js/lib/src/shared/beacon_guard.ts @@ -50,6 +50,40 @@ function extractUrl(input: RequestInfo | URL): string | null { return null; } +function sameDescriptor( + left: PropertyDescriptor | undefined, + right: PropertyDescriptor | undefined +): boolean { + if (!left || !right) return left === right; + if (left.configurable !== right.configurable || left.enumerable !== right.enumerable) { + return false; + } + if ('value' in left || 'value' in right) { + return ( + 'value' in left && + 'value' in right && + left.value === right.value && + left.writable === right.writable + ); + } + return left.get === right.get && left.set === right.set; +} + +function restoreOwnedDescriptor( + target: object, + property: PropertyKey, + installed: PropertyDescriptor | undefined, + original: PropertyDescriptor | undefined +): void { + try { + if (!sameDescriptor(Object.getOwnPropertyDescriptor(target, property), installed)) return; + if (original) Object.defineProperty(target, property, original); + else Reflect.deleteProperty(target, property); + } catch { + // Publisher replacement or a hostile descriptor cannot block independent cleanup. + } +} + /** * Create an independent beacon guard for a specific integration. */ @@ -57,8 +91,10 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { let installed = false; let originalSendBeacon: typeof navigator.sendBeacon | undefined; let originalSendBeaconDescriptor: PropertyDescriptor | undefined; + let installedSendBeaconDescriptor: PropertyDescriptor | undefined; let originalFetch: typeof window.fetch | undefined; let originalFetchDescriptor: PropertyDescriptor | undefined; + let installedFetchDescriptor: PropertyDescriptor | undefined; let sendBeaconPatched = false; let fetchPatched = false; const prefix = `${config.name} beacon guard`; @@ -82,7 +118,7 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { originalSendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); sendBeaconPatched = true; - navigator.sendBeacon = function (url: string, data?: BodyInit | null): boolean { + const wrapper = function (url: string, data?: BodyInit | null): boolean { const sendBeacon = originalSendBeacon; if (!sendBeacon) return false; if (config.isTargetUrl(url)) { @@ -92,6 +128,12 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { } return Reflect.apply(sendBeacon, navigator, [url, data]); }; + navigator.sendBeacon = wrapper; + installedSendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + sendBeaconPatched = sameDescriptor(installedSendBeaconDescriptor, { + ...installedSendBeaconDescriptor, + value: wrapper, + }); } // --- Patch window.fetch --- @@ -100,7 +142,7 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { originalFetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); fetchPatched = true; - window.fetch = function (input: RequestInfo | URL, init?: RequestInit): Promise { + const wrapper = function (input: RequestInfo | URL, init?: RequestInit): Promise { const fetch = originalFetch; if (!fetch) return Promise.reject(new TypeError('fetch is unavailable')); const url = extractUrl(input); @@ -119,6 +161,12 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { return Reflect.apply(fetch, window, [input, init]); }; + window.fetch = wrapper; + installedFetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + fetchPatched = sameDescriptor(installedFetchDescriptor, { + ...installedFetchDescriptor, + value: wrapper, + }); } installed = true; @@ -131,23 +179,22 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { function reset(): void { if (sendBeaconPatched && typeof navigator !== 'undefined') { - if (originalSendBeaconDescriptor) { - Object.defineProperty(navigator, 'sendBeacon', originalSendBeaconDescriptor); - } else { - Reflect.deleteProperty(navigator, 'sendBeacon'); - } + restoreOwnedDescriptor( + navigator, + 'sendBeacon', + installedSendBeaconDescriptor, + originalSendBeaconDescriptor + ); } if (fetchPatched && typeof window !== 'undefined') { - if (originalFetchDescriptor) { - Object.defineProperty(window, 'fetch', originalFetchDescriptor); - } else { - Reflect.deleteProperty(window, 'fetch'); - } + restoreOwnedDescriptor(window, 'fetch', installedFetchDescriptor, originalFetchDescriptor); } originalSendBeacon = undefined; originalSendBeaconDescriptor = undefined; + installedSendBeaconDescriptor = undefined; originalFetch = undefined; originalFetchDescriptor = undefined; + installedFetchDescriptor = undefined; sendBeaconPatched = false; fetchPatched = false; installed = false; 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 838c51b77..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 @@ -4,16 +4,16 @@ 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); @@ -31,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', () => { @@ -155,9 +163,160 @@ describe('Beacon Guard', () => { guard.install(); guard.reset(); - expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual( + 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); }); From 86f8aa7b6ce86ffbcc3e5acbf34558a2a4a4dd58 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:33:28 -0700 Subject: [PATCH 161/194] Reconcile GPT-first render trace terminals --- .../trusted-server-js/lib/src/core/trace.ts | 19 +++ .../lib/test/composition/browser.test.ts | 153 ++++++++++++++++++ .../lib/test/core/trace_runtime.test.ts | 68 ++++++++ 3 files changed, 240 insertions(+) diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 00daa12c2..a59f9709f 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -964,6 +964,7 @@ export function createRenderTraceDiagnostics( object, { readonly baselineSequence: number | undefined; + reconciled?: boolean; renderEnded?: boolean; sequence?: number; readonly slotId: string; @@ -1065,6 +1066,24 @@ export function createRenderTraceDiagnostics( history.some((candidate) => candidate.seq === record.seq); const record = (input: RenderTraceInputV1): Readonly => { + if (!disposed && input.path !== 'gam-refresh') { + for (const impression of gptImpressions.values()) { + if ( + impression.slotId !== input.slotId || + impression.renderEnded !== true || + impression.reconciled === true || + impression.sequence === undefined || + current.get(input.slotId)?.seq !== impression.sequence + ) { + continue; + } + const reconciled = enrich(impression.sequence, input); + if (reconciled) { + impression.reconciled = true; + return reconciled; + } + } + } const previous = current.get(input.slotId); let at: number; try { diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 4ec11e107..f6297b68e 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -1141,6 +1141,159 @@ describe('browser composition', () => { } }); + 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 = createBrowserRuntimeComposition( + { + target, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [{ id: 'gpt_diagnostics', required: true }], + }, + knownIntegrationIds: Object.freeze(['gpt_diagnostics']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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( + 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', + }); + 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 = {}; 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 index abded6af1..572aaa214 100644 --- a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -226,6 +226,74 @@ describe('render trace diagnostics runtime', () => { 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 token = Object.freeze(Object.create(null) as object); + const slot = Object.freeze({ token, elementId: 'reverse-slot' }); + const resolve = () => + Object.freeze({ slotId: 'reverse-slot', elementId: 'reverse-slot', 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 token = Object.freeze(Object.create(null) as object); From 1b1255679e00a25900d8de0d7b66bff6fc9e34bf Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:40:25 -0700 Subject: [PATCH 162/194] Isolate Lockr cleanup failures --- .../lib/src/integrations/lockr/module.ts | 15 ++++++-- .../test/integrations/lockr/module.test.ts | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/lockr/module.ts b/crates/trusted-server-js/lib/src/integrations/lockr/module.ts index 0b91d31ed..77512c7a2 100644 --- a/crates/trusted-server-js/lib/src/integrations/lockr/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/lockr/module.ts @@ -24,6 +24,14 @@ export interface LockrRuntimeDependencies { readonly timedOut: () => void; } +function bestEffort(action: () => void): void { + try { + action(); + } catch { + // Cleanup is isolated so one hostile publisher hook cannot retain another resource. + } +} + /** Own the Lockr guard, bounded SDK readiness timer, and installed API host. */ export function createLockrRuntime( dependencies: LockrRuntimeDependencies = { @@ -78,11 +86,12 @@ export function createLockrRuntime( active = false; started = false; if (timer !== undefined) { - dependencies.clearTimeout(timer); + const ownedTimer = timer; timer = undefined; + bestEffort(() => dependencies.clearTimeout(ownedTimer)); } - resetSdk(); - dependencies.resetGuard(); + bestEffort(resetSdk); + bestEffort(dependencies.resetGuard); }; }, start: (_config: unknown): void => { 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 index d44677b10..2581fbd7c 100644 --- a/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts @@ -84,4 +84,39 @@ describe('transactional Lockr integration module', () => { 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(); + }); }); From ccdea4599d0e0b8d337ec0d7c69a73c7f7a4fb50 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:54:25 -0700 Subject: [PATCH 163/194] Keep render trace counts aligned with current slots --- .../trusted-server-js/lib/src/core/trace.ts | 25 +++++++----- .../lib/test/core/trace_runtime.test.ts | 38 +++++++++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index a59f9709f..89eb4a02d 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -1085,6 +1085,10 @@ export function createRenderTraceDiagnostics( } } 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)(); @@ -1093,10 +1097,16 @@ export function createRenderTraceDiagnostics( } const previousCount = counts.get(input.slotId) ?? 0; if (!counts.has(input.slotId) && counts.size >= MAX_RENDER_TRACE_SLOTS) { - const oldestCount = counts.keys().next().value as string | undefined; - if (oldestCount !== undefined) counts.delete(oldestCount); + let evictedCounter: string | undefined; + for (const candidate of counts.keys()) { + if (!current.has(candidate)) { + evictedCounter = candidate; + break; + } + } + evictedCounter ??= evictedCurrentSlot; + if (evictedCounter !== undefined) counts.delete(evictedCounter); } - counts.delete(input.slotId); counts.set(input.slotId, previousCount + 1); const committed = copyRenderTraceRecord({ ...input, @@ -1105,12 +1115,9 @@ export function createRenderTraceDiagnostics( at, }); if (disposed) return committed; - if (!previous && current.size >= MAX_RENDER_TRACE_SLOTS) { - const oldestSlot = current.keys().next().value as string | undefined; - if (oldestSlot !== undefined) { - current.delete(oldestSlot); - presentation.prune(oldestSlot); - } + if (evictedCurrentSlot !== undefined) { + current.delete(evictedCurrentSlot); + presentation.prune(evictedCurrentSlot); } current.set(committed.slotId, committed); recordsBySequence.set(committed.seq, committed); 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 index 572aaa214..d88f2122c 100644 --- a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -159,6 +159,44 @@ describe('render trace diagnostics runtime', () => { 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('retains pruned counts until bounded capacity requires their eviction', () => { + 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(1); + }); + it('retains impression bookkeeping and refuses truth-weakening enrichment', () => { const { owner } = harness(); const record = owner.record({ From 372a570f4265b711fb11410e40b35b6158afad96 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:59:44 -0700 Subject: [PATCH 164/194] Capture GPT subscriber membership at commit --- .../src/integrations/gpt_diagnostics/api.ts | 4 +- .../src/integrations/gpt_diagnostics/store.ts | 13 ++++ .../integrations/gpt_diagnostics/api.test.ts | 74 +++++++++++++++++++ .../gpt_diagnostics/store.test.ts | 21 ++++++ 4 files changed, 110 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts index 5b1bbcb3c..0cd2d62d1 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts @@ -6,7 +6,7 @@ import type { GptDiagnosticsStoreSnapshot } from './store'; interface ApiStore { snapshot(): GptDiagnosticsStoreSnapshot; - subscribe(listener: () => void): () => void; + subscribeCommits(listener: () => void): () => void; } interface ApiBindingManager { @@ -76,7 +76,7 @@ export class GptDiagnosticsApiController { this.document = options.document ?? document; this.now = options.now ?? (() => new Date()); this.schedule = options.schedule ?? scheduleTask; - this.unsubscribeStore = this.store.subscribe(() => this.scheduleNotification()); + this.unsubscribeStore = this.store.subscribeCommits(() => this.scheduleNotification()); this.unsubscribeBindings = this.bindings.subscribe(() => this.scheduleNotification()); this.api = Object.freeze({ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts index 9183b6e09..add2c591d 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts @@ -140,6 +140,7 @@ export class GptDiagnosticsStore { private readonly slots = new Map(); private readonly slotOrder: number[] = []; private readonly slotActivityOrder: number[] = []; + private readonly commitListeners = new Set(); private readonly listeners = new Set(); private readonly coverage = emptyCoverage(); private readonly callbackIssues: GptDiagnosticsCallbackIssue[] = []; @@ -168,6 +169,11 @@ export class GptDiagnosticsStore { return () => this.listeners.delete(listener); } + subscribeCommits(listener: StoreListener): () => void { + this.commitListeners.add(listener); + return () => this.commitListeners.delete(listener); + } + recordSlotRequested(slot: GptDiagnosticsSlotLike, observedAtMs?: number): void { const timestampMs = this.timestamp(observedAtMs); const record = this.prepareCallback('slotRequested', slot, timestampMs); @@ -509,6 +515,13 @@ export class GptDiagnosticsStore { } private notify(): void { + for (const listener of [...this.commitListeners]) { + try { + listener(); + } catch { + // One correctness observer must not block the committed store mutation. + } + } if (this.notificationScheduled) return; this.notificationScheduled = true; this.schedule(() => { 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 b6d90f4b4..de83d106a 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 @@ -192,6 +192,80 @@ describe('GptDiagnosticsApiController', () => { ); }); + 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), + }); + 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); + + 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 }), 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 6cb6b0b9e..b57252a7e 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 @@ -412,6 +412,27 @@ describe('GptDiagnosticsStore', () => { expect(goodListener).toHaveBeenCalledTimes(1); }); + it('announces correctness commits synchronously while coalescing presentation work', () => { + const scheduled: Array<() => void> = []; + const store = new GptDiagnosticsStore({ + now: () => 1, + schedule: (callback) => scheduled.push(callback), + }); + const commitListener = vi.fn(); + const presentationListener = vi.fn(); + store.subscribeCommits(commitListener); + store.subscribe(presentationListener); + + store.markGptObserved(); + store.recordSlotRequested(fakeSlot('commit-membership')); + + expect(commitListener).toHaveBeenCalledTimes(2); + expect(presentationListener).not.toHaveBeenCalled(); + expect(scheduled).toHaveLength(1); + scheduled.shift()?.(); + expect(presentationListener).toHaveBeenCalledOnce(); + }); + it('returns detached snapshot data', () => { const store = new GptDiagnosticsStore({ now: () => 1 }); const slot = fakeSlot('detached'); From 8cf810c4b0caf177b1f7f0189d168551bff59d8d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:02:40 -0700 Subject: [PATCH 165/194] Exercise maximal runtime failure isolation --- .../test/composition/maximal-runtime.test.ts | 415 +++++++++++++++++- 1 file changed, 414 insertions(+), 1 deletion(-) 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 index 3a1f507f5..2b9f4816d 100644 --- a/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts +++ b/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts @@ -53,7 +53,8 @@ function generatedIntegrationIds(): readonly string[] { function tracedRegistration( registration: IntegrationRegistration, - events: string[] + events: string[], + failAfterActivation?: string ): IntegrationRegistration { return Object.freeze({ id: registration.id, @@ -66,6 +67,9 @@ function tracedRegistration( 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`); + } }, }); }, @@ -85,6 +89,246 @@ function integrationConfig(id: string): unknown { return undefined; } +interface MaximalHarnessOptions { + readonly configOverrides?: Readonly>; + readonly failAfterActivation?: string; +} + +interface TrackedListener { + readonly capture: boolean; + readonly listener: EventListenerOrEventListenerObject; + readonly target: EventTarget; + readonly type: string; +} + +function captureOption(options?: boolean | AddEventListenerOptions): boolean { + return typeof options === 'boolean' ? options : options?.capture === true; +} + +function createMaximalHarness(options: MaximalHarnessOptions = {}) { + const integrationIds = generatedIntegrationIds(); + 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); + }); + // 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 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; + window.addEventListener('message', listener, true); + let active = true; + return (): void => { + if (!active) return; + active = false; + activeCaptureListeners -= 1; + 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: { + version: 1, + releaseId: TEST_RELEASE_ID, + integrations: integrationIds.map((id) => ({ id, required: true })), + }, + knownIntegrationIds: integrationIds, + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + 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() }, + }, + { + 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 registrations) { + expect(composition.runtime.registerIntegration(registration)).toBe(true); + events.push(`register:${registration.id}`); + } + + 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(); + Object.defineProperty(eventTargetPrototype, 'addEventListener', addDescriptor); + Object.defineProperty(eventTargetPrototype, 'removeEventListener', removeDescriptor); + }; + + return Object.freeze({ + assertReleased, + composition, + events, + integrationIds, + 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(); @@ -236,4 +480,173 @@ describe('generated maximal browser runtime transaction', () => { expect(Object.getOwnPropertyDescriptor(window, 'didomiConfig')).toEqual(didomiBefore); expect(Object.getOwnPropertyDescriptor(window, 'testlight')).toEqual(testlightBefore); }); + + it.each([ + { + name: 'a real activation fails after acquiring its composed effects', + failureId: 'permutive', + phase: 'activate' as const, + }, + { + name: 'one real registration receives malformed frozen config', + failureId: 'sourcepoint', + 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.integrationIds.indexOf(failureId); + const preparedIds = + phase === 'activate' + ? harness.integrationIds + : harness.integrationIds.slice(0, failureIndex + 1); + const activatedIds = + phase === 'activate' ? harness.integrationIds.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.integrationIds.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', phase: 'after_commit' }] : []; + + expect(installed).toEqual({ + state: 'kernel', + runtimeFailures: expectedRuntimeFailures, + dispose: expect.any(Function), + }); + expect(harness.composition.runtime.state).toBe('kernel'); + 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.composition.auctionContextRegistryForTest()?.snapshotInventoryForTest() + ).toEqual({ disposed: false, registrations: ['permutive'] }); + 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'] : []; + expect(harness.events.filter((event) => event.startsWith('dispose:'))).toEqual( + disposedBeforeRuntimeRelease + ); + await harness.assertReleased(); + const reverseIds = [...harness.integrationIds].reverse(); + const expectedDisposals = + kind === 'storage' + ? [ + 'dispose:sourcepoint', + ...reverseIds.filter((id) => id !== 'sourcepoint').map((id) => `dispose:${id}`), + ] + : reverseIds.map((id) => `dispose:${id}`); + expect(harness.events.filter((event) => event.startsWith('dispose:'))).toEqual( + expectedDisposals + ); + } finally { + falsePositiveScript?.remove(); + harness.restoreInstrumentation(); + } + }); }); From 6eb445c78e6fa58b4ba8f079c4b2eca75fdf37fb Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:37:09 -0700 Subject: [PATCH 166/194] Switch production to the resilient TSJS runtime --- crates/trusted-server-adapter-axum/src/app.rs | 34 +- .../trusted-server-adapter-axum/src/main.rs | 72 +- .../tests/routes.rs | 16 +- .../src/app.rs | 30 +- .../src/lib.rs | 11 +- .../tests/routes.rs | 16 +- .../trusted-server-adapter-fastly/src/app.rs | 74 +- .../trusted-server-adapter-fastly/src/main.rs | 6 - crates/trusted-server-adapter-spin/src/app.rs | 32 +- crates/trusted-server-adapter-spin/src/lib.rs | 1 - .../tests/routes.rs | 72 +- .../src/auction/endpoints.rs | 118 +- .../src/auction/formats.rs | 91 +- .../trusted-server-core/src/auction/types.rs | 18 + crates/trusted-server-core/src/auth.rs | 5 +- .../trusted-server-core/src/html_processor.rs | 259 +- .../src/integrations/aps.rs | 16 +- .../src/integrations/didomi.rs | 12 +- .../src/integrations/gpt.rs | 57 +- .../src/integrations/mod.rs | 18 +- .../src/integrations/prebid.rs | 11 +- .../src/integrations/registry.rs | 56 +- .../src/integrations/sourcepoint.rs | 116 +- .../trusted-server-core/src/platform/mod.rs | 1 - .../trusted-server-core/src/platform/types.rs | 1 - crates/trusted-server-core/src/publisher.rs | 1117 ++--- crates/trusted-server-core/src/tsjs.rs | 154 + crates/trusted-server-js/lib/build-all.mjs | 16 +- .../lib/src/adapters/googletag.ts | 112 + .../lib/src/composition/browser.ts | 490 +- .../lib/src/composition/index.ts | 7 + .../trusted-server-js/lib/src/core/auction.ts | 2 + .../src/core/contracts/auction_projection.ts | 52 +- .../trusted-server-js/lib/src/core/index.ts | 246 +- .../trusted-server-js/lib/src/core/release.ts | 6 + .../trusted-server-js/lib/src/core/types.ts | 10 + .../lib/src/integrations/creative/index.ts | 44 +- .../lib/src/integrations/datadome/index.ts | 28 +- .../lib/src/integrations/didomi/index.ts | 9 +- .../integrations/google_tag_manager/index.ts | 36 +- .../lib/src/integrations/gpt/index.ts | 108 +- .../lib/src/integrations/gpt/module.ts | 68 +- .../src/integrations/gpt_diagnostics/index.ts | 12 + .../lib/src/integrations/lockr/index.ts | 110 +- .../lib/src/integrations/osano/index.ts | 13 +- .../lib/src/integrations/permutive/index.ts | 119 +- .../lib/src/integrations/prebid/index.ts | 31 +- .../lib/src/integrations/sourcepoint/index.ts | 20 +- .../lib/src/integrations/testlight/index.ts | 87 +- .../lib/src/kernel/fallback.ts | 1 + .../lib/src/kernel/runtime.ts | 6 +- .../lib/src/services/projections.ts | 43 +- .../lib/src/services/slots.ts | 30 +- .../lib/test/adapters/googletag.test.ts | 66 + .../lib/test/composition/browser.test.ts | 501 ++- .../test/composition/maximal-runtime.test.ts | 2 + .../lib/test/core/auction.test.ts | 35 +- .../lib/test/core/index.test.ts | 169 +- .../test/integrations/creative/click.test.ts | 18 +- .../lib/test/integrations/creative/helpers.ts | 44 +- .../test/integrations/creative/iframe.test.ts | 6 +- .../test/integrations/creative/image.test.ts | 6 +- .../lib/test/integrations/gpt/ad_init.test.ts | 3932 ----------------- .../integrations/gpt/gpt_bootstrap.test.ts | 10 + .../lib/test/integrations/gpt/index.test.ts | 453 -- .../lib/test/integrations/gpt/module.test.ts | 15 + .../gpt/schedule_initial_ad_init.test.ts | 347 -- .../test/integrations/gpt/spa_hook.test.ts | 625 --- .../test/integrations/prebid/index.test.ts | 93 - .../integrations/sourcepoint/index.test.ts | 56 +- .../lib/test/kernel/fallback.test.ts | 1 + .../lib/test/kernel/runtime.test.ts | 13 + .../test/prebid-artifact-integration.test.mjs | 154 - .../lib/test/services/projections.test.ts | 46 +- .../lib/test/services/slots.test.ts | 2 + crates/trusted-server-js/lib/vitest.config.ts | 12 + ...8-04-aps-tsjs-resilience-implementation.md | 36 +- ...s-render-fix-and-tsjs-resilience-design.md | 97 +- 78 files changed, 3204 insertions(+), 7655 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/composition/index.ts delete mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts delete mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts delete mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts delete mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index ee7f57b50..28d429e13 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, @@ -71,9 +71,6 @@ fn build_state_with_settings( settings: Settings, ) -> Result, Report> { let orchestrator = build_orchestrator(&settings)?; - #[cfg(feature = "aps-runner-proxy-integration-test")] - let registry = IntegrationRegistry::new_with_aps_v1_for_tests(&settings)?; - #[cfg(not(feature = "aps-runner-proxy-integration-test"))] let registry = IntegrationRegistry::new(&settings)?; Ok(Arc::new(AppState { @@ -83,7 +80,6 @@ fn build_state_with_settings( })) } -#[cfg(feature = "aps-runner-proxy-integration-test")] async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option { if !state.registry.has_reserved_path(req.uri().path()) { return None; @@ -95,25 +91,23 @@ async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Opt .registry .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) .await - .expect("reserved path should have a coordinated-cutover handler") + .expect("reserved path should have a hard-cutover handler") .unwrap_or_else(|report| http_error(&report)), ) } -#[cfg(feature = "aps-runner-proxy-integration-test")] #[derive(Clone)] -/// Feature-artifact dispatcher that owns one startup-built APS registry. +/// Dispatcher that owns one startup-built registry for hard-cutover route families. pub struct ReservedApsDispatcher { state: Arc, } -#[cfg(feature = "aps-runner-proxy-integration-test")] impl ReservedApsDispatcher { /// Build the dispatcher from the adapter's startup settings. /// /// # Errors /// - /// Returns an error when settings, the orchestrator, or the APS test + /// Returns an error when settings, the orchestrator, or the integration /// registry cannot be initialized. pub fn from_startup_settings() -> Result> { Ok(Self { @@ -125,7 +119,7 @@ impl ReservedApsDispatcher { /// /// # Errors /// - /// Returns an error when the orchestrator or APS test registry cannot be + /// Returns an error when the orchestrator or integration registry cannot be /// initialized from `settings`. pub fn from_settings(settings: Settings) -> Result> { Ok(Self { @@ -139,12 +133,11 @@ impl ReservedApsDispatcher { } } -#[cfg(feature = "aps-runner-proxy-integration-test")] /// Dispatch a reserved APS request using explicit settings. /// /// # Errors /// -/// Returns an error when the feature-only dispatcher cannot be initialized. +/// Returns an error when the dispatcher cannot be initialized. pub async fn dispatch_reserved_with_settings( settings: Settings, req: Request, @@ -154,12 +147,11 @@ pub async fn dispatch_reserved_with_settings( .await) } -#[cfg(feature = "aps-runner-proxy-integration-test")] /// Dispatch a reserved APS request using startup settings. /// /// # Errors /// -/// Returns an error when startup settings or the feature-only dispatcher +/// Returns an error when startup settings or the dispatcher /// cannot be initialized. pub async fn dispatch_reserved( req: Request, @@ -377,7 +369,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 14] { +fn named_routes() -> [NamedRoute; 13] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -435,14 +427,6 @@ 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`. - NamedRoute { - path: PAGE_BIDS_LEGACY_PATH, - primary_methods: &[Method::GET, Method::OPTIONS], - handler: NamedRouteHandler::PageBids, - }, NamedRoute { path: "/first-party/proxy", primary_methods: &[Method::GET], diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 8e22dedd4..7e0efdd37 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,11 +1,14 @@ -#[cfg(not(feature = "aps-runner-proxy-integration-test"))] -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; -#[cfg(not(feature = "aps-runner-proxy-integration-test"))] +#[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}"); } @@ -21,48 +24,27 @@ fn main() { None => AxumDevServerConfig::default(), }; - 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); - } -} - -#[cfg(feature = "aps-runner-proxy-integration-test")] -#[tokio::main] -#[allow(clippy::print_stderr)] -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}"); - } - let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port_from_env().unwrap_or(8787))); let dispatcher = trusted_server_adapter_axum::app::ReservedApsDispatcher::from_startup_settings() - .expect("APS feature artifact should build its reserved dispatcher"); + .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); - } - }; + 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 route family" + "reserved APS entry route reached a request outside its family" ); Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR) } @@ -79,11 +61,23 @@ async fn main() { .route("/integrations/aps", reserved.clone()) .route("/integrations/aps/{*rest}", reserved) .fallback_service(EdgeZeroAxumService::new(TrustedServerApp::routes())); - let listener = tokio::net::TcpListener::bind(addr) + let listener = tokio::net::TcpListener::bind(config.addr) .await - .expect("APS feature artifact should bind its configured address"); - log::info!("Listening on http://{addr}"); - if let Err(error) = axum::serve(listener, app).await { + .expect("should bind the configured address"); + log::info!("Listening on http://{}", config.addr); + 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/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index c1fc7e28f..0e16bcfab 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -54,7 +54,6 @@ fn test_router() -> edgezero_core::router::RouterService { .expect("should build router from test settings") } -#[cfg(feature = "aps-runner-proxy-integration-test")] async fn route_reserved(request: Request) -> axum::http::Response { let request = edgezero_adapter_axum::request::into_core_request(request) .await @@ -102,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"), @@ -123,6 +115,11 @@ fn all_explicit_routes_are_registered() { for (method, path) in expected { assert_route_registered(method, path); } + let routes = registered_routes(); + assert!( + routes.iter().all(|(_, path)| path != "/__ts/page-bids"), + "hard cutover must not retain the deprecated page-bids alias: {routes:?}" + ); } /// Verify the legacy non-`/_ts` admin aliases ARE registered — to the local @@ -233,7 +230,6 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { ); } -#[cfg(feature = "aps-runner-proxy-integration-test")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn aps_cutover_renderer_and_family_failures_are_local() { let renderer = Request::builder() diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index fe8b618f1..a29a58fee 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, @@ -109,9 +108,6 @@ fn build_state_with_settings( settings: Settings, ) -> Result, Report> { let orchestrator = build_orchestrator(&settings)?; - #[cfg(feature = "aps-runner-proxy-integration-test")] - let registry = IntegrationRegistry::new_with_aps_v1_for_tests(&settings)?; - #[cfg(not(feature = "aps-runner-proxy-integration-test"))] let registry = IntegrationRegistry::new(&settings)?; Ok(Arc::new(AppState { @@ -121,7 +117,6 @@ fn build_state_with_settings( })) } -#[cfg(feature = "aps-runner-proxy-integration-test")] async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option { if !state.registry.has_reserved_path(req.uri().path()) { return None; @@ -133,12 +128,11 @@ async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Opt .registry .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) .await - .expect("reserved path should have a coordinated-cutover handler") + .expect("reserved path should have a hard-cutover handler") .unwrap_or_else(|report| http_error(&report)), ) } -#[cfg(feature = "aps-runner-proxy-integration-test")] /// Dispatch a reserved request using explicit settings. /// /// # Errors @@ -152,7 +146,6 @@ pub async fn dispatch_reserved_with_settings( Ok(dispatch_reserved_for_state(&state, req).await) } -#[cfg(feature = "aps-runner-proxy-integration-test")] /// Dispatch a reserved request using the configured adapter state. /// /// # Errors @@ -591,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 { @@ -613,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 { diff --git a/crates/trusted-server-adapter-cloudflare/src/lib.rs b/crates/trusted-server-adapter-cloudflare/src/lib.rs index b28f40cbb..3ab7d3434 100644 --- a/crates/trusted-server-adapter-cloudflare/src/lib.rs +++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs @@ -15,10 +15,7 @@ pub mod platform; #[cfg(target_arch = "wasm32")] use worker::{Context, Env, Request, Response, Result, event}; -#[cfg(all( - feature = "aps-runner-proxy-integration-test", - any(target_arch = "wasm32", test) -))] +#[cfg(any(target_arch = "wasm32", test))] fn preserved_reserved_method(value: &str) -> Option { edgezero_core::http::Method::from_bytes(value.as_bytes()).ok() } @@ -36,11 +33,9 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { app::set_cloudflare_config_json(config.to_string()); } - #[cfg(feature = "aps-runner-proxy-integration-test")] let is_reserved = req .url() .is_ok_and(|url| trusted_server_core::integrations::aps::is_aps_family_path(url.path())); - #[cfg(feature = "aps-runner-proxy-integration-test")] if is_reserved { // workers-rs maps unknown methods to GET; the underlying Fetch request // preserves the original method token, so capture it before conversion. @@ -56,7 +51,7 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { .map_err(|error| worker::Error::RustError(error.to_string()))? .ok_or_else(|| { worker::Error::RustError( - "reserved APS path has no coordinated-cutover handler".to_string(), + "reserved APS path has no hard-cutover handler".to_string(), ) })?; return edgezero_adapter_cloudflare::response::from_core_response(response) @@ -72,7 +67,7 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { } } -#[cfg(all(test, feature = "aps-runner-proxy-integration-test"))] +#[cfg(test)] mod tests { use super::preserved_reserved_method; diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index dbbea3288..0c2c3db6f 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -70,7 +70,6 @@ async fn route(router: RouterService, req: Request) -> Response { router.oneshot(req).await.expect("should route request") } -#[cfg(feature = "aps-runner-proxy-integration-test")] async fn route_reserved(req: Request) -> Response { trusted_server_adapter_cloudflare::app::dispatch_reserved_with_settings(test_settings(), req) .await @@ -121,7 +120,6 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } -#[cfg(feature = "aps-runner-proxy-integration-test")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn aps_cutover_renderer_and_family_failures_are_local() { let renderer = request_builder() @@ -303,16 +301,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"), @@ -324,6 +315,11 @@ fn all_explicit_routes_are_registered() { for (method, path) in expected { assert_route_registered(method, path); } + let routes = registered_routes(); + assert!( + routes.iter().all(|(_, path)| path != "/__ts/page-bids"), + "hard cutover must not retain the deprecated page-bids alias: {routes:?}" + ); for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { for method in LEGACY_ADMIN_DENY_METHODS { diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index ab1260257..b61566b55 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,7 +164,6 @@ pub(crate) fn build_state() -> Result, Report> build_state_from_settings(load_settings_from_config_store()?) } -#[cfg(feature = "aps-runner-proxy-integration-test")] pub(crate) async fn dispatch_reserved_for_state( state: &Arc, req: Request, @@ -180,7 +178,7 @@ pub(crate) async fn dispatch_reserved_for_state( .registry .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) .await - .expect("reserved path should have a coordinated-cutover handler") + .expect("reserved path should have a hard-cutover handler") .unwrap_or_else(|report| http_error(&report)), ) } @@ -197,9 +195,6 @@ pub(crate) fn build_state_from_settings( warn_if_certificate_check_disabled(&settings); let orchestrator = build_orchestrator(&settings)?; - #[cfg(feature = "aps-runner-proxy-integration-test")] - let registry = IntegrationRegistry::new_with_aps_v1_for_tests(&settings)?; - #[cfg(not(feature = "aps-runner-proxy-integration-test"))] let registry = IntegrationRegistry::new(&settings)?; let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings); @@ -1133,16 +1128,6 @@ 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. - NamedRoute { - path: PAGE_BIDS_LEGACY_PATH, - primary_methods: &[Method::GET, Method::OPTIONS], - handler: NamedRouteHandler::PageBids, - }, NamedRoute { path: "/first-party/proxy", primary_methods: &[Method::GET], @@ -1272,8 +1257,8 @@ mod tests { #[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; @@ -1780,45 +1765,26 @@ 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_serves_only_the_canonical_path() { + // The hard cutover exposes only the canonical single-underscore path. + // Pin the literal the client fetches and reject accidental reintroduction + // of the former compatibility alias. 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 + 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]); + assert!( + 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" - ); - } + .all(|route| route.path != "/__ts/page-bids"), + "hard cutover must not retain the deprecated page-bids alias" + ); } #[test] diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 352506874..94b22e3bd 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -167,7 +167,6 @@ 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); - #[cfg(feature = "aps-runner-proxy-integration-test")] let routed = if let Some(state) = app_state .as_ref() .filter(|state| state.registry.has_reserved_path(core_req.uri().path())) @@ -181,8 +180,6 @@ fn edgezero_main(mut req: FastlyRequest) { } else { futures::executor::block_on(app.router().oneshot(core_req)) }; - #[cfg(not(feature = "aps-runner-proxy-integration-test"))] - let routed = futures::executor::block_on(app.router().oneshot(core_req)); match routed { Ok(response) => response, Err(error) => edge_error_response(error), @@ -202,14 +199,11 @@ fn edgezero_main(mut req: FastlyRequest) { let asset_cache_policy = response.extensions_mut().remove::(); let request_filter_effects = response.extensions_mut().remove::(); - #[cfg(feature = "aps-runner-proxy-integration-test")] let should_finalize = response .extensions() .get::() .is_none() && !take_finalize_sentinel(&mut response); - #[cfg(not(feature = "aps-runner-proxy-integration-test"))] - let should_finalize = !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); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 0ce351336..245ccf173 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -22,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, @@ -82,9 +81,6 @@ fn build_state_with_settings( settings: Settings, ) -> Result, Report> { let orchestrator = build_orchestrator(&settings)?; - #[cfg(feature = "aps-runner-proxy-integration-test")] - let registry = IntegrationRegistry::new_with_aps_v1_for_tests(&settings)?; - #[cfg(not(feature = "aps-runner-proxy-integration-test"))] let registry = IntegrationRegistry::new(&settings)?; Ok(Arc::new(AppState { @@ -94,7 +90,6 @@ fn build_state_with_settings( })) } -#[cfg(feature = "aps-runner-proxy-integration-test")] async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option { if !state.registry.has_reserved_path(req.uri().path()) { return None; @@ -106,17 +101,16 @@ async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Opt .registry .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) .await - .expect("reserved path should have a coordinated-cutover handler") + .expect("reserved path should have a hard-cutover handler") .unwrap_or_else(|report| http_error(&report)), ) } -#[cfg(feature = "aps-runner-proxy-integration-test")] /// Dispatch a reserved APS request using explicit settings. /// /// # Errors /// -/// Returns an error when the feature-only application state cannot be +/// Returns an error when the application state cannot be /// initialized from `settings`. pub async fn dispatch_reserved_with_settings( settings: Settings, @@ -126,12 +120,11 @@ pub async fn dispatch_reserved_with_settings( Ok(dispatch_reserved_for_state(&state, req).await) } -#[cfg(feature = "aps-runner-proxy-integration-test")] /// Dispatch a reserved APS request using startup settings. /// /// # Errors /// -/// Returns an error when startup settings or the feature-only application +/// Returns an error when startup settings or the application /// state cannot be initialized. pub async fn dispatch_reserved( req: Request, @@ -209,7 +202,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 14] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -220,7 +213,6 @@ 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]), ("/first-party/proxy", &[Method::GET]), ("/first-party/click", &[Method::GET]), ("/first-party/sign", &[Method::GET, Method::POST]), @@ -843,18 +835,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) diff --git a/crates/trusted-server-adapter-spin/src/lib.rs b/crates/trusted-server-adapter-spin/src/lib.rs index bb43c2eff..5a6b20bc1 100644 --- a/crates/trusted-server-adapter-spin/src/lib.rs +++ b/crates/trusted-server-adapter-spin/src/lib.rs @@ -13,7 +13,6 @@ 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 { - #[cfg(feature = "aps-runner-proxy-integration-test")] 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) diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index e0e797f0a..c7fdf514d 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -60,7 +60,6 @@ async fn route(router: RouterService, req: Request) -> Response { router.oneshot(req).await.expect("should route request") } -#[cfg(feature = "aps-runner-proxy-integration-test")] async fn route_reserved(req: Request) -> Response { trusted_server_adapter_spin::app::dispatch_reserved_with_settings(test_settings(), req) .await @@ -75,7 +74,6 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } -#[cfg(feature = "aps-runner-proxy-integration-test")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn aps_cutover_renderer_and_family_failures_are_local() { let renderer = request_builder() @@ -417,53 +415,35 @@ 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 hard cutover +/// leaves the former double-underscore alias to the publisher fallback. #[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; + let alias_body = + String::from_utf8_lossy(&former_alias.into_body().into_bytes().unwrap_or_default()) + .into_owned(); + assert!( + !alias_body.contains("Creative opportunities not configured"), + "former compatibility alias must not reach page-bids" ); } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index bab6fe9bd..e4a16b3e0 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, AuctionDecisionSetV1, AuctionSlotFailureReason}; +use super::types::{ + AuctionContext, AuctionDecisionSetV1, AuctionRequest, AuctionSlotFailureReason, + SlotAuctionDecisionV1, SystemAuctionIdentityGenerator, +}; const MAX_CLIENT_EID_SOURCES: usize = 64; const MAX_CLIENT_UIDS_PER_SOURCE: usize = 32; @@ -44,6 +45,66 @@ const MAX_CLIENT_EID_SOURCE_BYTES: usize = 255; /// arbitrary WASM linear memory. const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; +struct ExactAuctionResponseV1 { + response: Response, + delivered_winner_slots: HashSet, + dropped_winner_count: usize, +} + +fn exact_auction_response_v1( + result: &OrchestrationResult, + settings: &Settings, + auction_request: &AuctionRequest, + request_origin: &str, + ec_allowed: bool, +) -> Result> { + let price_granularity = settings + .creative_opportunities + .as_ref() + .map(|config| config.price_granularity) + .unwrap_or_default(); + let canonical = crate::publisher::coordinated_cutover_v1::build_browser_auction_projection_v1( + result, + price_granularity, + settings, + request_origin, + None, + &SystemAuctionIdentityGenerator, + )?; + let body = crate::auction::formats::coordinated_cutover_v1::serialize_trusted_server_auction_response_v1( + &canonical, + )?; + let delivered_winner_slots: HashSet = canonical + .projection + .auction + .results + .iter() + .filter_map(|decision| match decision { + SlotAuctionDecisionV1::Winner { slot, .. } => Some(slot.clone()), + _ => None, + }) + .collect(); + let projected_winner_count = result + .decision_set + .results + .iter() + .filter(|decision| matches!(decision, SlotAuctionDecisionV1::Winner { .. })) + .count(); + let mut response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(EdgeBody::from(body)) + .change_context(TrustedServerError::Auction { + message: "Failed to build exact auction response".to_string(), + })?; + attach_auction_response_headers(&mut response, auction_request, ec_allowed)?; + Ok(ExactAuctionResponseV1 { + response, + dropped_winner_count: projected_winner_count.saturating_sub(delivered_winner_slots.len()), + delivered_winner_slots, + }) +} + /// Handle auction request from `POST /auction`. /// /// Accepts a JSON body matching [`AdRequest`][`super::formats::AdRequest`]. @@ -163,6 +224,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. @@ -223,12 +300,14 @@ pub async fn handle_auction( total_time_ms: 0, metadata: HashMap::new(), }; - return convert_to_openrtb_response( + return Ok(exact_auction_response_v1( &empty_result, settings, &auction_request, + &request_origin, ec_context.ec_allowed(), - ); + )? + .response); } // Parse client-provided EIDs from the current request body. When the @@ -325,10 +404,11 @@ pub async fn handle_auction( } }; - let conversion = match convert_to_openrtb_response_with_report( + let conversion = match exact_auction_response_v1( &result, settings, &auction_request, + &request_origin, ec_context.ec_allowed(), ) { Ok(conversion) => conversion, @@ -356,7 +436,7 @@ pub async fn handle_auction( AuctionTerminalOutcome::Completed { request: &auction_request, result: &result, - delivered_winner_slots: Some(&conversion.delivery.delivered_winner_slots), + delivered_winner_slots: Some(&conversion.delivered_winner_slots), }, ) }) @@ -365,8 +445,8 @@ pub async fn handle_auction( log::info!( "Auction completed: {} providers, {} delivered winning bids, {} dropped winners, {}ms total", result.provider_responses.len(), - conversion.delivery.delivered_winner_slots.len(), - conversion.delivery.dropped_winner_count, + conversion.delivered_winner_slots.len(), + conversion.dropped_winner_count, result.total_time_ms ); @@ -740,6 +820,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 bc804a2c4..e8d9b8ab6 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -31,7 +31,7 @@ use super::orchestrator::OrchestrationResult; use super::types::{ AdFormat, AdSlot, AuctionDecisionSetV1, AuctionDropReason, AuctionDropReasons, AuctionRequest, AuctionSlotFailureReason, BidRenderSourceV1, BrowserAuctionBidV1, BrowserAuctionProjectionV1, - CacheFetchPolicyV1, DeviceInfo, MAX_BROWSER_AUCTION_PROJECTION_BYTES, + 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, @@ -311,6 +311,35 @@ pub(crate) struct OpenRtbResponseConversion { pub delivery: AuctionDeliveryReport, } +/// 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" @@ -517,6 +546,18 @@ pub(crate) mod coordinated_cutover_v1 { && 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> { @@ -566,6 +607,29 @@ pub(crate) mod coordinated_cutover_v1 { 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") @@ -623,6 +687,7 @@ pub(crate) mod coordinated_cutover_v1 { auction_id: input.auction.auction_id, results: canonical_results, }, + slots: input.slots, bids: canonical_bids, }; let mut json = @@ -949,27 +1014,7 @@ pub(crate) fn convert_to_openrtb_response_with_report( message: "Failed to build auction response".to_string(), })?; - // Signal consent status independently of whether EIDs were resolved. - if ec_allowed { - response - .headers_mut() - .insert(HEADER_X_TS_EC_CONSENT, HeaderValue::from_static("ok")); - } - - // Attach EID response headers when consent-gated EIDs are available. - if let Some(ref eids) = auction_request.user.eids { - let (encoded, truncated) = encode_eids_header(eids)?; - let header_val = - HeaderValue::from_str(&encoded).change_context(TrustedServerError::Auction { - message: "Failed to encode EIDs header value".to_string(), - })?; - response.headers_mut().insert(HEADER_X_TS_EIDS, header_val); - if truncated { - response - .headers_mut() - .insert(HEADER_X_TS_EIDS_TRUNCATED, HeaderValue::from_static("true")); - } - } + attach_auction_response_headers(&mut response, auction_request, ec_allowed)?; Ok(OpenRtbResponseConversion { response, delivery }) } @@ -2498,6 +2543,7 @@ mod convert_tests { auction_id: "auction-1".to_string(), results, }, + slots: Vec::new(), bids, } } @@ -2692,6 +2738,7 @@ mod convert_tests { reason: crate::auction::types::AuctionSlotFailureReason::IdentityGenerationFailed, }], }, + slots: Vec::new(), bids: Vec::new(), }, "https://publisher.example", diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 7be13d7d5..407ba4972 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -503,6 +503,22 @@ pub struct BrowserAuctionBidV1 { 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)] pub struct BrowserAuctionProjectionV1 { @@ -510,6 +526,8 @@ pub struct BrowserAuctionProjectionV1 { 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, } 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/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 9047a7db3..754ac5cb8 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,13 @@ 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":[]},"bids":[]}"#; + /// Wraps [`HtmlRewriterAdapter`] with optional post-processing. /// /// When `post_processors` is empty (the common streaming path), chunks pass @@ -176,6 +173,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 +198,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 +228,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 +321,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,7 +334,7 @@ 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() { @@ -342,23 +348,74 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso { snippet.push_str(&cleanup_tag); } - // 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); - } let ctx = IntegrationHtmlContext { request_host: &patterns.request_host, request_scheme: &patterns.request_scheme, 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("", "trailing-content ".repeat(3 * 1024)); let page = format!("hello{trailing_comment}"); let compressed = gzip_encode(page.as_bytes()); @@ -8993,6 +9013,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 { @@ -9021,10 +9042,11 @@ 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":"test-auction""#), + "should collect the exact projection before 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" @@ -9061,6 +9083,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(); @@ -9112,6 +9135,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, }; @@ -9221,6 +9245,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(); @@ -9279,6 +9304,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, }; @@ -11005,119 +11031,6 @@ mod tests { .expect("should return ok response") } - /// 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. @@ -11213,7 +11126,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(); @@ -11222,26 +11135,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. @@ -11258,25 +11164,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(); @@ -11287,19 +11190,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" ); @@ -11332,7 +11225,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(), @@ -11340,7 +11235,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" ); } @@ -11355,19 +11250,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" ); @@ -11417,7 +11302,7 @@ 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 @@ -11431,26 +11316,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 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 @@ -11464,19 +11339,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" ); diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 1d71a981d..7794f4f8d 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -37,6 +37,92 @@ pub fn tsjs_boot_manifest_v1(module_ids: &[&str]) -> Result { + /// 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() @@ -189,6 +275,74 @@ mod tests { } } + #[test] + fn boot_script_serializes_the_exact_hard_cutover_transport_and_mark() { + let script = tsjs_boot_script_v1(TsjsBootScriptConfigV1 { + module_ids: &["creative", "gpt", "gpt_diagnostics"], + auction_projection_json: + r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"bids":[]}"#, + creative: CreativeBootConfigV1 { + enabled: true, + click_guard: true, + render_guard: false, + }, + render_trace_overlay: true, + gpt_diagnostics_active: true, + }) + .expect("should serialize boot transport"); + + assert!(script.starts_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":[]},"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":[]},"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"]); diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index d28d4c264..a4225afdd 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -21,6 +21,7 @@ import { brotliCompressSync, constants as zlibConstants, gzipSync } from 'node:z import { fileURLToPath } from 'node:url'; import { build } from 'vite'; +import { discoverIntegrationModules } from './scripts/integration-inventory-v1.mjs'; import { computeReleaseId, RELEASE_SENTINEL, stampRelease } from './scripts/release-v1.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -66,17 +67,7 @@ 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() - : []; +const integrationModules = discoverIntegrationModules(integrationsDir); console.log('[build-all] Discovered integrations:', integrationModules); @@ -89,6 +80,7 @@ async function buildModule(name, entryPath, outFile = `tsjs-${name}.js`) { root: __dirname, define: { __TSJS_EMBEDDED_RELEASE_ID_V1__: JSON.stringify(RELEASE_SENTINEL), + __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: JSON.stringify(integrationModules), }, build: { emptyOutDir: false, @@ -115,7 +107,7 @@ async function buildModule(name, entryPath, outFile = `tsjs-${name}.js`) { } // Build core first (synchronously), then all integrations in parallel -await buildModule('core', path.join(srcDir, 'core', 'index.ts')); +await buildModule('core', path.join(srcDir, 'composition', 'index.ts')); await Promise.all( integrationModules.map((name) => buildModule(name, path.join(integrationsDir, name, 'index.ts'))) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index a540046b8..af6b1fe44 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -39,6 +39,25 @@ export interface GoogletagReplacementCommitAdmission { 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 } @@ -153,6 +172,11 @@ 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( @@ -166,6 +190,7 @@ export interface GoogletagFacade { 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) => void): () => void; transactionalReplace( @@ -553,6 +578,92 @@ function createFacade( 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'); @@ -691,6 +802,7 @@ function createFacade( }, 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 ( diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 6aa3df2bd..cd474c1b1 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -24,6 +24,7 @@ import { parseTrustedServerAuctionResponseV1 } from '../core/auction'; import type { BootManifestV1, BrowserAuctionProjectionV1, + BrowserAuctionSlotV1, CreativeBootV1, DiagnosticsBootV1, } from '../core/types'; @@ -220,6 +221,7 @@ export interface TestBrowserRuntimeCompositionOptions extends BrowserComposition readonly createIdentityIssuerForTest?: NavigationIdentityIssuerFactory; readonly admittedProgrammaticSlotsForTest?: readonly string[]; readonly gptStartupForTest?: (config: unknown) => void; + readonly pageBidsFetcherForTest?: PageBidsFetcher; readonly prebidStartupForTest?: (config: unknown) => void; readonly pucSchedulerForTest?: PucBridgeOptions['scheduler']; } @@ -235,18 +237,241 @@ interface AcceptedBrowserBoot { } interface PreparedBrowserServices { - readonly createAttempt: (owner: RenderAttemptScope) => ReturnType; + readonly createAttempt: ( + owner: RenderAttemptScope, + parentAttemptId?: string + ) => ReturnType; readonly publisherOrigin: string; + readonly renderProjectedFallback: (attempt: RenderAttempt) => boolean; readonly rendererUrl: string; readonly resolveCacheAdm: NonNullable; readonly services: Readonly>; } -function projectionSlots(projection: object): readonly string[] { - const accepted = projection as { - readonly auction: { readonly results: readonly { readonly slot: string }[] }; +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(accepted.auction.results.map(({ slot }) => slot)); + + 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 { @@ -426,6 +651,9 @@ export function createBrowserRuntimeComposition( 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 diagnosticsBus: DiagnosticsBus | undefined; @@ -570,6 +798,20 @@ export function createBrowserRuntimeComposition( 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: () => { @@ -580,10 +822,34 @@ export function createBrowserRuntimeComposition( start: startGpt, }); const gptIntegrationRuntime = Object.freeze({ - activate: gptRuntime.activate, - start: gptRuntime.start, + 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 runtimeSession: RuntimeSession | undefined; let prebidCoordinator: PrebidSelectionCoordinator | undefined; let prebidRefreshConfig = EMPTY_PREBID_REFRESH_CONFIG; const startPrebid = compositionOptions.prebidStartupForTest ?? (() => undefined); @@ -736,7 +1002,168 @@ export function createBrowserRuntimeComposition( target: window as typeof window & { testlight?: { que?: unknown[] } }, }); let auctionBatchService: AuctionBatchService | undefined; - let projectionParser: ((candidate: unknown) => object | undefined) | 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, + 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 = ( @@ -1109,10 +1536,11 @@ export function createBrowserRuntimeComposition( } }; const fetchAuction = compositionOptions.auctionFetcherForTest ?? globalThis.fetch; - const createOwnedAttempt = (owner: RenderAttemptScope) => + 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; @@ -1120,6 +1548,19 @@ export function createBrowserRuntimeComposition( publishDiagnostics: preparedDiagnosticsBus.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, @@ -1128,19 +1569,7 @@ export function createBrowserRuntimeComposition( return fetchAuction(input, init); }, parseResponse: parseTrustedServerAuctionResponseV1, - renderWinner: (attempt) => { - 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; - }, + renderWinner: renderProjectedFallback, }); const services = Object.freeze({ artifacts, @@ -1156,6 +1585,7 @@ export function createBrowserRuntimeComposition( preparedBrowserServices = Object.freeze({ createAttempt: createOwnedAttempt, publisherOrigin, + renderProjectedFallback, rendererUrl, resolveCacheAdm, services, @@ -1217,9 +1647,11 @@ export function createBrowserRuntimeComposition( const navigation = session.startInitialNavigation(initialProjection); if (!navigation.ok) throw new Error(navigation.reason); + const acceptedInitialProjection = initialProjection as Readonly; const initialRegistrations = [ - ...projectionSlots(initialProjection).map((registeredSlotId) => ({ - registeredSlotId, + ...acceptedInitialProjection.slots.map((placement) => ({ + domAliases: Object.freeze([placement.divId]), + registeredSlotId: placement.slot, source: 'server' as const, })), ...(compositionOptions.admittedProgrammaticSlotsForTest ?? []).map((registeredSlotId) => ({ @@ -1271,6 +1703,14 @@ export function createBrowserRuntimeComposition( }); 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({ 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 0fac2fd01..5757e3a98 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -244,6 +244,8 @@ export function parseTrustedServerAuctionResponseV1( 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) { 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 index 1c29574d2..8e0254fab 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts @@ -6,6 +6,7 @@ import type { BidRenderSourceV1, BrowserAuctionBidV1, BrowserAuctionProjectionV1, + BrowserAuctionSlotV1, CacheFetchPolicyV1, CacheRenderSourceV1, SlotAuctionDecisionV1, @@ -17,6 +18,7 @@ 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; @@ -571,6 +573,37 @@ function parseBrowserBid( }; } +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, @@ -580,11 +613,24 @@ export function parseBrowserAuctionProjectionV1( const cachePolicy = cachePolicyValue === undefined ? undefined : parseCacheFetchPolicyV1(cachePolicyValue); if (cachePolicyValue !== undefined && !cachePolicy) return undefined; - const record = ownDataObject(value, ['version', 'auction', 'bids']); + 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 || !rawBids) return undefined; + 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(); @@ -613,7 +659,7 @@ export function parseBrowserAuctionProjectionV1( } if (winnerIndex !== bids.length) return undefined; - const projection: BrowserAuctionProjectionV1 = { version: 1, auction, bids }; + const projection: BrowserAuctionProjectionV1 = { version: 1, auction, slots, bids }; if (jsonUtf8ByteLength(projection) > MAX_BROWSER_AUCTION_PROJECTION_BYTES) { return undefined; } diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index 807292008..93ef0a0b6 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -1,74 +1,210 @@ -// Public tsjs core bundle: sets up the global API, queue, and default methods. +// Sole production bootstrap for the resilient TSJS runtime. export type { + AddAdUnitsResult, AdUnit, GptDiagnosticsApi, GptDiagnosticsExportV1, GptDiagnosticsRequestCycle, - LegacyTsjsApi, + ProgrammaticAdUnit, + RequestAdsOptions, + RequestAdsResult, + TsjsApi, + TsjsBootV1, + TsjsDiagnostics, } from './types'; -// Erased coordinated-cutover types only. Production ownership remains below until Task 19. export type { Runtime, RuntimeOptions, RuntimeState } from '../kernel/runtime'; -import type { LegacyTsjsApi } 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'; -const VERSION = '0.1.0'; +import type { Runtime, RuntimeOptions } from '../kernel/runtime'; -const w: Window & { tsjs?: LegacyTsjsApi } = - ((globalThis as unknown as { window?: Window }).window as Window & { - tsjs?: LegacyTsjsApi; - }) || ({} as Window & { tsjs?: LegacyTsjsApi }); +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: LegacyTsjsApi = (w.tsjs ??= {} as LegacyTsjsApi); -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 the server-side ad stack runs for the request. When it -// is gated off (kill switch, consent fail-closed, 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 consumeIntegrationConfig( + target: BootstrapTarget +): Readonly> | undefined { + try { + const descriptor = Object.getOwnPropertyDescriptor(target, '_integrationConfig'); + if (!descriptor) return Object.freeze({}); + if (!('value' in descriptor) || !descriptor.configurable) return undefined; + const candidate = descriptor.value; + 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; + } + if (!Reflect.deleteProperty(target, '_integrationConfig')) return undefined; + return Object.freeze(configs); + } catch { + return undefined; + } +} + +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); + const composition = createComposition( + { + target, + releaseId: EMBEDDED_RELEASE_ID, + manifest: configs ? bootManifest(target) : undefined, + 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/release.ts b/crates/trusted-server-js/lib/src/core/release.ts index 125b2ccde..7256c269d 100644 --- a/crates/trusted-server-js/lib/src/core/release.ts +++ b/crates/trusted-server-js/lib/src/core/release.ts @@ -1,4 +1,10 @@ declare const __TSJS_EMBEDDED_RELEASE_ID_V1__: string; +declare const __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: 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__, +]); diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 7f8d5c798..1a347ef04 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -125,9 +125,19 @@ export interface BrowserAuctionBidV1 { renderSource: BidRenderSourceV1; } +/** Exact GAM placement metadata required to publish one server-projected slot. */ +export interface BrowserAuctionSlotV1 { + slot: string; + gamUnitPath: string; + divId: string; + formats: ReadonlyArray; + targeting: Record; +} + export interface BrowserAuctionProjectionV1 { version: 1; auction: AuctionDecisionSetV1; + slots: BrowserAuctionSlotV1[]; bids: BrowserAuctionBidV1[]; } 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 e3589e496..fda336bbf 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/index.ts @@ -1,12 +1,14 @@ -// 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'; +// Legacy callable helpers remain exported until Task 22; production performs +// only the release-bound integration registration below. +import { EMBEDDED_RELEASE_ID } from '../../core/release'; +import type { TsCreativeConfig, TsCreativeApi } from '../../shared/globals'; +import { creativeGlobal } from '../../shared/globals'; import { installClickGuard } from './click'; import { installDynamicImageProxy } from './image'; import { installDynamicIframeProxy } from './iframe'; import type { CreativeGuardHandle } from './startup'; +import { createCreativeIntegrationRegistration } from './module'; export { installDynamicImageProxy } from './image'; export { installDynamicIframeProxy } from './iframe'; @@ -88,32 +90,14 @@ export const tsCreative: TsCreativeApi = { 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 (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 (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/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/didomi/index.ts b/crates/trusted-server-js/lib/src/integrations/didomi/index.ts index 3595b2f9a..f073f757a 100644 --- a/crates/trusted-server-js/lib/src/integrations/didomi/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/didomi/index.ts @@ -1,4 +1,7 @@ import { log } from '../../core/log'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; + +import { createDidomiIntegrationRegistration } from './module'; const DEFAULT_CONSENT_PROXY_PATH = '/integrations/didomi/consent/'; @@ -47,7 +50,11 @@ export function installDidomiSdkProxy(): boolean { } 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/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/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts index 52d0f62c5..ca1dff27f 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -8,7 +8,11 @@ import { isAuctionCandidateIdV1, isRendererReservationIdV1, } from '../../core/contracts/auction_projection'; -import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../core/types'; +import type { + BrowserAuctionBidV1, + BrowserAuctionProjectionV1, + BrowserAuctionSlotV1, +} from '../../core/types'; import type { NavigationSession } from '../../kernel/sessions'; import { createSlotOperation, @@ -80,6 +84,7 @@ export interface GptWinnerPublicationInput extends Omit< readonly bid: BrowserAuctionBidV1; readonly googletag: GoogletagAdapter; readonly navigation: NavigationSession; + readonly placement: BrowserAuctionSlotV1; readonly pucBridge: Pick; readonly reservations: Pick; readonly slot: object; @@ -92,15 +97,20 @@ function currentProjectedWinner(input: GptWinnerPublicationInput): boolean { 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 || @@ -126,6 +136,14 @@ function currentProjectedWinner(input: GptWinnerPublicationInput): boolean { } } 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]; @@ -145,31 +163,45 @@ function currentProjectedWinner(input: GptWinnerPublicationInput): boolean { } function targetingEntries( - bid: BrowserAuctionBidV1 + bid: BrowserAuctionBidV1, + placement: BrowserAuctionSlotV1 ): readonly (readonly [string, string])[] | undefined { try { - const unsortedNames = objectGetOwnPropertyNamesIntrinsic(bid.targeting); - const names: string[] = []; - for (let index = 0; index < unsortedNames.length; index += 1) { - const name = unsortedNames[index]; - if (name === undefined) return undefined; - let insertion = names.length; - while (insertion > 0 && (names[insertion - 1] as string) > name) insertion -= 1; - for (let move = names.length; move > insertion; move -= 1) { - names[move] = names[move - 1] as string; - } - names[insertion] = name; - } - if (names.length > 32 || objectGetOwnPropertySymbolsIntrinsic(bid.targeting).length !== 0) { + 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 || key === 'hb_adid') return undefined; - const descriptor = objectGetOwnPropertyDescriptorIntrinsic(bid.targeting, key); + if (!key) return undefined; + const bidDescriptor = objectGetOwnPropertyDescriptorIntrinsic(bid.targeting, key); + const placementDescriptor = objectGetOwnPropertyDescriptorIntrinsic(placement.targeting, key); + const descriptor = bidDescriptor ?? placementDescriptor; if ( !descriptor || !descriptor.enumerable || @@ -266,7 +298,7 @@ export async function publishGptWinner( disposeArtifact(); return failAttempt('slot_unresolved'); } - const entries = targetingEntries(input.bid); + const entries = targetingEntries(input.bid, input.placement); if (!entries) { disposeArtifact(); return failAttempt('descriptor_invalid'); diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts index e552f9112..e7a78af16 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts @@ -1,4 +1,5 @@ import type { GptDiagnosticsApi } from '../../core/types'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; import { GptDiagnosticsApiController } from './api'; import { GptDiagnosticsBadgeManager } from './badges'; @@ -7,6 +8,7 @@ import type { GptDiagnosticsFactBuffer } from './facts'; import { GptDiagnosticsObserver } from './observer'; import { GptDiagnosticsOverlay } from './overlay'; import { GptDiagnosticsStore } from './store'; +import { createGptDiagnosticsIntegrationRegistration } from './module'; type GptDiagnosticsWindow = Window & typeof globalThis; @@ -105,3 +107,13 @@ export function createGptDiagnosticsRuntime( currentApi: (): GptDiagnosticsApi | undefined => active?.api, }); } + +if (typeof window !== 'undefined') { + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createGptDiagnosticsIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/lockr/index.ts b/crates/trusted-server-js/lib/src/integrations/lockr/index.ts index e7b98e2cf..3d94101d7 100644 --- a/crates/trusted-server-js/lib/src/integrations/lockr/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/lockr/index.ts @@ -1,107 +1,11 @@ -import { log } from '../../core/log'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -import { installLockrGuard } from './script_guard'; - -// Type definition for Lockr global -declare const identityLockr: IdentityLockr | undefined; - -interface IdentityLockr { - host: string; - app_id: string; - expiryDateKeys: string[]; - firstPartyCookies: string[]; - canRefreshToken: boolean; - macroDetectionEnabled: boolean; - iluiMacroDetection: boolean; - gdprApplies: boolean; - consentString: string; - gppString: string; - ccpaString: string; - isUTMTagsLoaded: boolean; - isFirstPartyCookiesLoaded: boolean; - allowedUTMTags: string[]; - lockrTrackingID: string; - panoramaClientId: string; - writeToDeviceConsentEUID: boolean; - id5JSEnabled: boolean; - firstIDPassHEM: boolean; - panoramaPassHEM: boolean; - firstIDEnabled: boolean; - panoramaEnabled: boolean; - isAdelphicEnabled: boolean; - os: string; - browser: string; - country: string; - city: string; - latitude: string; - longitude: string; - ip: string; - hashedUserAgent: string; - tokenMappings: Record; - tokenSourceMappings: Record; - identitProvidersType: Record; - identityIdEncryptionSalt: string; -} - -/** - * Install the Lockr shim to rewrite API endpoints to first-party domain. - * This function is called after the Lockr SDK has loaded and initialized. - */ -function installLockrShim() { - log.info('Installing Lockr shim - rewriting API host to first-party domain'); - - if (typeof identityLockr === 'undefined' || !identityLockr) { - log.warn('Lockr shim: identityLockr global not found'); - return; - } - - const host = window.location.host; - const protocol = window.location.protocol === 'https:' ? 'https' : 'http'; - - // Store original host for debugging - const originalHost = identityLockr.host; - - // Rewrite to first-party domain - // The Lockr SDK will now make all API calls through our proxy - identityLockr.host = `${protocol}://${host}/integrations/lockr/api`; - - log.info('Lockr shim installed', { - originalHost, - newHost: identityLockr.host, - appId: identityLockr.app_id, - }); -} - -/** - * Wait for Lockr SDK to be available before installing shim. - * Polls for SDK availability with a maximum number of attempts. - * - * @param callback - Function to call when SDK is available - * @param maxAttempts - Maximum number of polling attempts (default: 50) - */ -function waitForLockrSDK(callback: () => void, maxAttempts = 50) { - let attempts = 0; - - const check = () => { - attempts++; - - // Check if identityLockr global exists and is initialized with host - if (typeof identityLockr !== 'undefined' && identityLockr && identityLockr.host) { - log.info('Lockr SDK detected, installing shim'); - callback(); - } else if (attempts < maxAttempts) { - // Check again in 50ms - setTimeout(check, 50); - } else { - log.warn('Lockr SDK not detected after', maxAttempts * 50, 'ms'); - } - }; - - check(); -} +import { createLockrIntegrationRegistration } from './module'; if (typeof window !== 'undefined') { - installLockrGuard(); - - waitForLockrSDK(() => installLockrShim()); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [createLockrIntegrationRegistration(EMBEDDED_RELEASE_ID)]); + } } diff --git a/crates/trusted-server-js/lib/src/integrations/osano/index.ts b/crates/trusted-server-js/lib/src/integrations/osano/index.ts index 135b44591..afd29ff9a 100644 --- a/crates/trusted-server-js/lib/src/integrations/osano/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/osano/index.ts @@ -4,7 +4,14 @@ export { mirrorOsanoConsent, } from './consent_mirror'; -import { initializeOsanoConsentMirror } from './consent_mirror'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -// Legacy entry point retained until the coordinated Task 19 wiring cutover. -initializeOsanoConsentMirror(); +import { createOsanoIntegrationRegistration } 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, [createOsanoIntegrationRegistration(EMBEDDED_RELEASE_ID)]); + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/permutive/index.ts b/crates/trusted-server-js/lib/src/integrations/permutive/index.ts index 60eb5134a..6385eac8a 100644 --- a/crates/trusted-server-js/lib/src/integrations/permutive/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/permutive/index.ts @@ -1,114 +1,13 @@ -import { log } from '../../core/log'; -import { registerContextProvider } from '../../core/context'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -import { installPermutiveGuard } from './script_guard'; -import { getPermutiveSegments } from './segments'; - -declare const permutive: { - config: { - advertiserApiVersion: string; - apiHost: string; - apiKey: string; - apiProtocol: string; - apiVersion: string; - cdnBaseUrl: string; - cdnProtocol: string; - classificationModelsApiVersion: string; - consentRequired: boolean; - cookieDomain: string; - cookieExpiry: string; - cookieName: string; - environment: string; - eventsCacheLimitBytes: number; - eventsTTLInDays: number | null; - localStorageDebouncedKeys: string[]; - localStorageWriteDelay: number; - localStorageWriteMaxDelay: number; - loggingEnabled: boolean; - metricsSamplingPercentage: number; - permutiveDataMiscKey: string; - permutiveDataQueriesKey: string; - prebidAuctionsRandomDownsamplingThreshold: number; - pxidHost: string; - requestTimeout: number; - sdkErrorsApiVersion: string; - sdkType: string; - secureSignalsApiHost: string; - segmentSyncApiHost: string; - sendClientErrors: boolean; - stateNamespace: string; - tracingEnabled: boolean; - viewId: string; - watson: { - enabled: boolean; - }; - windowKey: string; - workspaceId: string; - }; -}; - -function installPermutiveShim() { - log.info('Installing Permutive shim - rewriting API hosts to first-party domain'); - - const host = window.location.host; - const protocol = window.location.protocol === 'https:' ? 'https' : 'http'; - - permutive.config.apiHost = host + '/integrations/permutive/api'; - permutive.config.apiProtocol = protocol; - - permutive.config.secureSignalsApiHost = host + '/integrations/permutive/secure-signal'; - - permutive.config.segmentSyncApiHost = host + '/integrations/permutive/sync'; - - permutive.config.cdnBaseUrl = host + '/integrations/permutive/cdn'; - permutive.config.cdnProtocol = protocol; - - log.info('Permutive shim installed', { - apiHost: permutive.config.apiHost, - secureSignalsApiHost: permutive.config.secureSignalsApiHost, - segmentSyncApiHost: permutive.config.segmentSyncApiHost, - cdnBaseUrl: permutive.config.cdnBaseUrl, - }); -} - -/** - * Wait for Permutive SDK to be available before installing shim. - * Polls for SDK availability with a maximum number of attempts. - * - * @param callback - Function to call when SDK is available - * @param maxAttempts - Maximum number of polling attempts (default: 50) - */ -function waitForPermutiveSDK(callback: () => void, maxAttempts = 50) { - let attempts = 0; - - const check = () => { - attempts++; - - // Check if permutive global exists and is initialized with config - if (typeof permutive !== 'undefined' && permutive?.config) { - log.info('Permutive SDK detected, installing shim'); - callback(); - } else if (attempts < maxAttempts) { - // Check again in 50ms - setTimeout(check, 50); - } else { - log.warn('Permutive SDK not detected after', maxAttempts * 50, 'ms'); - } - }; - - check(); -} +import { createPermutiveIntegrationRegistration } from './module'; if (typeof window !== 'undefined') { - installPermutiveGuard(); - - // Register a context provider so Permutive segments are included in auction - // requests. Core calls collectContext() before every /auction POST — this - // keeps all Permutive localStorage knowledge inside this integration. - registerContextProvider('permutive', () => { - const segments = getPermutiveSegments(); - return segments.length > 0 ? { permutive_segments: segments } : undefined; - }); - - waitForPermutiveSDK(() => installPermutiveShim()); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createPermutiveIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); + } } diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 4a25670ff..3e4606207 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -14,6 +14,7 @@ import type _pbjsDefault from 'prebid.js'; import { log } from '../../core/log'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; import { isEffectivelyVisible, recordRender, stampCreativeTrace } from '../../core/trace'; import { buildAdRequest, @@ -25,6 +26,7 @@ import type { AuctionBid, AuctionEid } from '../../core/auction'; import type { AuctionSlot, BrowserAuctionBidV1, RenderRecord } from '../../core/types'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; +import { createPrebidIntegrationRegistration } from './module'; /** * Prebid.js public API surface (type-only; erased at build time). @@ -1695,30 +1697,13 @@ export function installPrebidRenderTrace(): void { listen('adRenderFailed', 'failed'); } -// Self-initialize when loaded in a browser (same pattern as other integrations). if (typeof window !== 'undefined') { - installPrebidNpm(); - // When the external bundle failed to load, installPrebidNpm bailed out and - // pbjs.requestBids is undefined. Installing the refresh handler anyway - // would clear TS-applied GPT targeting on every publisher refresh and then - // fail to run the replacement auction — leave GPT untouched instead. - if (hasPrebidJsApi()) { - installRefreshHandler(); - installPrebidRenderTrace(); - // The slim-Prebid lazy loader appends this bundle from a window.load - // handler, so `load` may already have fired by the time this code runs — - // waiting for it again would skip user ID setup entirely on that path. - if (document.readyState === 'complete') { - installUserIdModules(); - } else { - window.addEventListener( - 'load', - () => { - installUserIdModules(); - }, - { once: true } - ); - } + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createPrebidIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); } } diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts index 442d14760..298ce1ed2 100644 --- a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts @@ -1,7 +1,6 @@ -import { log } from '../../core/log'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -import { initializeSourcepointConsentMirror } from './consent_mirror'; -import { installSourcepointGuard } from './script_guard'; +import { createSourcepointIntegrationRegistration } from './module'; export { disposeSourcepointConsentMirror, @@ -9,15 +8,12 @@ export { mirrorSourcepointConsent, } from './consent_mirror'; -type SourcepointWindow = Window & { - __tsjs_sourcepoint?: { rewriteSdk?: boolean }; -}; - -// Legacy entry point retained until the coordinated Task 19 wiring cutover. if (typeof window !== 'undefined') { - if ((window as SourcepointWindow).__tsjs_sourcepoint?.rewriteSdk !== false) { - installSourcepointGuard(); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createSourcepointIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); } - initializeSourcepointConsentMirror(); - log.info('Sourcepoint integration initialized'); } diff --git a/crates/trusted-server-js/lib/src/integrations/testlight/index.ts b/crates/trusted-server-js/lib/src/integrations/testlight/index.ts index 8424d20af..450d54663 100644 --- a/crates/trusted-server-js/lib/src/integrations/testlight/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/testlight/index.ts @@ -1,82 +1,13 @@ -import type { LegacyTsjsApi } from '../../core/types'; -import { installQueue } from '../../core/queue'; -import { log } from '../../core/log'; -import { resolvePrebidWindow } from '../../shared/globals'; -import type { PrebidWindow } from '../../shared/globals'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -type TestlightCallback = () => void; - -type TestlightGlobal = { - que?: TestlightCallback[]; -}; - -type TestlightWindow = PrebidWindow & { - testlight?: TestlightGlobal; -}; - -function ensureTsjsApi(win: TestlightWindow): LegacyTsjsApi { - if (win.tsjs) return win.tsjs; - const stub: LegacyTsjsApi = { - version: '0.0.0', - que: [], - addAdUnits: () => undefined, - renderAdUnit: () => undefined, - renderAllAdUnits: () => undefined, - }; - win.tsjs = stub; - return stub; -} - -function installTestlightQueue(api: LegacyTsjsApi, win: TestlightWindow): void { - if (!Array.isArray(api.que)) { - installQueue(api, win); - } -} - -function flushCallbacks(queue: TestlightCallback[], api: LegacyTsjsApi): void { - while (queue.length > 0) { - const fn = queue.shift(); - if (typeof fn !== 'function') { - continue; - } - try { - if (Array.isArray(api.que)) { - api.que.push(fn); - } else { - fn.call(api); - } - log.debug('testlight shim: flushed callback'); - } catch (err) { - log.debug('testlight shim: queued callback threw', err); - } - } -} - -export function installTestlightShim(): boolean { - const win = resolvePrebidWindow() as TestlightWindow; - const api = ensureTsjsApi(win); - installTestlightQueue(api, win); - - const testlight = (win.testlight = win.testlight ?? {}); - const pending: TestlightCallback[] = Array.isArray(testlight.que) ? [...testlight.que] : []; - const queue: TestlightCallback[] = []; - testlight.que = queue; - - const originalPush = queue.push.bind(queue); - queue.push = function (...callbacks: TestlightCallback[]): number { - const len = originalPush(...callbacks); - flushCallbacks(queue, api); - return len; - }; - - if (pending.length > 0) { - queue.push(...pending); - } - - log.info('testlight shim installed', { queuedCallbacks: queue.length }); - return true; -} +import { createTestlightIntegrationRegistration } from './module'; if (typeof window !== 'undefined') { - installTestlightShim(); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createTestlightIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); + } } diff --git a/crates/trusted-server-js/lib/src/kernel/fallback.ts b/crates/trusted-server-js/lib/src/kernel/fallback.ts index 84b777d5e..b6c76951e 100644 --- a/crates/trusted-server-js/lib/src/kernel/fallback.ts +++ b/crates/trusted-server-js/lib/src/kernel/fallback.ts @@ -13,6 +13,7 @@ import type { BootFailureReason } from './integration_registry'; const SAFE_PROJECTION = { version: 1, auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], bids: [], } as const; diff --git a/crates/trusted-server-js/lib/src/kernel/runtime.ts b/crates/trusted-server-js/lib/src/kernel/runtime.ts index 191aafe5e..815aadb19 100644 --- a/crates/trusted-server-js/lib/src/kernel/runtime.ts +++ b/crates/trusted-server-js/lib/src/kernel/runtime.ts @@ -154,8 +154,10 @@ class RuntimeOwner implements Runtime { const bootCandidate = this.bootCandidate(); this.fallbackBoot = buildFallbackBoot(EMBEDDED_RELEASE_ID, bootCandidate); this.registry = createIntegrationRegistry({ - manifest: - this.options.releaseId === EMBEDDED_RELEASE_ID ? this.options.manifest : undefined, + // The manifest validator binds releaseId directly to the embedded build + // stamp, so a separate comparison would duplicate the stamp in minified + // core output without strengthening the ABI check. + manifest: this.options.manifest, releaseId: EMBEDDED_RELEASE_ID, knownIntegrationIds: this.options.knownIntegrationIds, startedAtMs, diff --git a/crates/trusted-server-js/lib/src/services/projections.ts b/crates/trusted-server-js/lib/src/services/projections.ts index 926ef2259..2199b211f 100644 --- a/crates/trusted-server-js/lib/src/services/projections.ts +++ b/crates/trusted-server-js/lib/src/services/projections.ts @@ -13,11 +13,17 @@ export interface PreparedProjectionSlots { readonly rollback: () => void; } +/** Exact slot identity and DOM aliases reserved with one admitted projection. */ +export interface ProjectionSlotRegistration { + readonly registeredSlotId: string; + readonly domAliases: readonly string[]; +} + /** Slot-registry transaction boundary consumed by the page-bids controller. */ export interface ProjectionSlotRegistry { readonly prepareProjectionSlots: ( ownerGeneration: object, - slots: readonly string[], + slots: readonly ProjectionSlotRegistration[], maximumActiveSlots: number ) => PreparedProjectionSlots | undefined; } @@ -72,31 +78,36 @@ function recursivelyFreeze(value: unknown, visited = new Set()): boolean } } -function projectedSlots(projection: object): readonly string[] | undefined { +function projectedSlots(projection: object): readonly ProjectionSlotRegistration[] | undefined { try { - const auctionDescriptor = Object.getOwnPropertyDescriptor(projection, 'auction'); - if (!auctionDescriptor || !('value' in auctionDescriptor)) return undefined; - const auction = auctionDescriptor.value; - if (typeof auction !== 'object' || auction === null) return undefined; - const resultsDescriptor = Object.getOwnPropertyDescriptor(auction, 'results'); - if (!resultsDescriptor || !('value' in resultsDescriptor)) return undefined; - const results = resultsDescriptor.value; - if (!Array.isArray(results) || results.length > MAX_ACTIVE_SLOT_RECORDS) return undefined; - const slots: string[] = []; + const slotsDescriptor = Object.getOwnPropertyDescriptor(projection, 'slots'); + if (!slotsDescriptor || !('value' in slotsDescriptor)) return undefined; + const projected = slotsDescriptor.value; + if (!Array.isArray(projected) || projected.length > MAX_ACTIVE_SLOT_RECORDS) return undefined; + const slots: ProjectionSlotRegistration[] = []; const seen = new Set(); - for (const result of results) { - if (typeof result !== 'object' || result === null) return undefined; - const slotDescriptor = Object.getOwnPropertyDescriptor(result, 'slot'); + for (const placement of projected) { + if (typeof placement !== 'object' || placement === null) return undefined; + const slotDescriptor = Object.getOwnPropertyDescriptor(placement, 'slot'); + const divDescriptor = Object.getOwnPropertyDescriptor(placement, 'divId'); if ( !slotDescriptor || !('value' in slotDescriptor) || - typeof slotDescriptor.value !== 'string' + typeof slotDescriptor.value !== 'string' || + !divDescriptor || + !('value' in divDescriptor) || + typeof divDescriptor.value !== 'string' ) { return undefined; } if (seen.has(slotDescriptor.value)) return undefined; seen.add(slotDescriptor.value); - slots.push(slotDescriptor.value); + slots.push( + Object.freeze({ + registeredSlotId: slotDescriptor.value, + domAliases: Object.freeze([divDescriptor.value]), + }) + ); } return Object.freeze(slots); } catch { diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index fcbfe584b..b7a43fb2e 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -17,7 +17,11 @@ import { } from '../adapters/googletag'; import type { NavigationSession } from '../kernel/sessions'; -import type { PreparedProjectionSlots, ProjectionSlotRegistry } from './projections'; +import type { + PreparedProjectionSlots, + ProjectionSlotRegistration, + ProjectionSlotRegistry, +} from './projections'; /** Shared maximum across server-projected and programmatically admitted slots. */ export const MAX_ACTIVE_SLOT_RECORDS = 256; @@ -149,7 +153,7 @@ export interface SlotService { ) => boolean; readonly prepareProjectionSlots: ( owner: NavigationSession, - slots: readonly string[] + slots: readonly ProjectionSlotRegistration[] ) => PreparedProjectionSlots | undefined; readonly claimPublisherGptSlot: ( call: GoogletagPublisherDefineSlotCall @@ -3133,19 +3137,27 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }, prepareProjectionSlots: ( owner: NavigationSession, - slots: readonly string[] + slots: readonly ProjectionSlotRegistration[] ): PreparedProjectionSlots | undefined => { if (!owner.isCurrent() || !Array.isArray(slots)) return undefined; - const copied = Object.freeze([...slots]); + let copied: readonly SlotRegistration[]; + try { + copied = Object.freeze( + slots.map((slot) => ({ + registeredSlotId: slot.registeredSlotId, + domAliases: Object.freeze([...slot.domAliases]), + source: 'server' as const, + })) + ); + } catch { + return undefined; + } let committedRecords: readonly SlotRecord[] | undefined; return Object.freeze({ ownerGeneration: owner.generation, commit: (): boolean => { if (committedRecords) return false; - const result = register( - owner, - copied.map((registeredSlotId) => ({ registeredSlotId, source: 'server' as const })) - ); + const result = register(owner, copied); if (!result.ok) return false; committedRecords = result.records; return true; @@ -3171,7 +3183,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { Object.freeze({ prepareProjectionSlots: ( ownerGeneration: object, - slots: readonly string[], + slots: readonly ProjectionSlotRegistration[], maximumActiveSlots: number ) => { if ( diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index 4b57e7c9d..6e073ee16 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -76,6 +76,72 @@ describe('browser googletag adapter readiness', () => { expect(ready.display).toHaveBeenCalledWith('slot-a'); }); + it('defines and adopts one GPT slot as a synchronous rollback-capable transaction', async () => { + const ready = createReadyGoogletag(); + const slot = { addService: vi.fn() }; + ready.googletag.defineSlot.mockReturnValue(slot); + ready.googletag.destroySlots.mockReturnValue(true); + const commit = vi.fn(() => true); + const rollback = vi.fn(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + + const operation = adapter.run((gpt) => + gpt.transactionalDefine( + { + adUnitPath: '/123/slot-a', + elementId: 'slot-a', + sizes: [[300, 250]], + }, + () => true, + (candidate) => { + expect(candidate).toBe(slot); + return Object.freeze({ commit, rollback }); + } + ) + ); + + await expect(operation.result).resolves.toEqual({ status: 'defined', slot }); + expect(ready.googletag.defineSlot).toHaveBeenCalledExactlyOnceWith( + '/123/slot-a', + [[300, 250]], + 'slot-a' + ); + expect(slot.addService).toHaveBeenCalledExactlyOnceWith(ready.pubads); + expect(commit).toHaveBeenCalledOnce(); + expect(rollback).not.toHaveBeenCalled(); + expect(ready.googletag.destroySlots).not.toHaveBeenCalled(); + expect(slot.addService.mock.invocationCallOrder[0]).toBeLessThan( + commit.mock.invocationCallOrder[0]! + ); + }); + + it('destroys a newly defined GPT slot when its navigation becomes stale before adoption', async () => { + const ready = createReadyGoogletag(); + const slot = { addService: vi.fn() }; + ready.googletag.defineSlot.mockReturnValue(slot); + ready.googletag.destroySlots.mockReturnValue(true); + const isGenerationCurrent = vi.fn().mockReturnValueOnce(true).mockReturnValue(false); + const prepareCommit = vi.fn(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + + const operation = adapter.run((gpt) => + gpt.transactionalDefine( + { + adUnitPath: '/123/slot-a', + elementId: 'slot-a', + sizes: [[300, 250]], + }, + isGenerationCurrent, + prepareCommit + ) + ); + + await expect(operation.result).resolves.toEqual({ status: 'discarded' }); + expect(prepareCommit).not.toHaveBeenCalled(); + expect(slot.addService).not.toHaveBeenCalled(); + expect(ready.googletag.destroySlots).toHaveBeenCalledExactlyOnceWith([slot]); + }); + it('marks and measures only the first TS-authoritative display', async () => { const ready = createReadyGoogletag(); const performance = { diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index f6297b68e..747621780 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -35,7 +35,7 @@ import { } from '../../src/composition/browser'; import { log as localLog } from '../../src/core/log'; import { TRACE_PANEL_ID } from '../../src/core/trace'; -import type { BrowserAuctionBidV1 } from '../../src/core/types'; +import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../src/core/types'; import { createCreativeIntegrationRegistration } from '../../src/integrations/creative/module'; import { createDataDomeIntegrationRegistration } from '../../src/integrations/datadome/module'; import { createDidomiIntegrationRegistration } from '../../src/integrations/didomi/module'; @@ -69,20 +69,51 @@ function createTarget() { }; } +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() { +function synchronousGptAdapter(initialSlots: readonly object[] = []) { const listeners = new Map void>>(); + const physicalSlots: object[] = [...initialSlots]; const targeting = new WeakMap>(); const bindingToken = Object.freeze({}); + const display = vi.fn(); const refresh = vi.fn(); const diagnosticsSlots = new WeakMap(); let diagnosticsObserver: GoogletagDiagnosticsObserver | undefined; let publisherObserver: GoogletagPublisherCallObserver | undefined; + 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' @@ -94,7 +125,8 @@ function synchronousGptAdapter() { if (key === undefined) values?.clear(); else values?.delete(key); }), - display: vi.fn(), + transactionalDefine, + display, getTargeting: vi.fn((slot: object, key: string) => Object.freeze([...(targeting.get(slot)?.get(key) ?? [])]) ), @@ -107,7 +139,11 @@ function synchronousGptAdapter() { targeting.set(slot, values); values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); }), - slots: () => Object.freeze([]), + slotElementId: (slot: object) => + 'getSlotElementId' in slot && typeof slot.getSlotElementId === 'function' + ? slot.getSlotElementId() + : undefined, + slots: () => Object.freeze([...physicalSlots]), subscribe: (eventType: string, listener: (event: unknown) => void) => { const registered = listeners.get(eventType) ?? new Set(); registered.add(listener); @@ -180,6 +216,7 @@ function synchronousGptAdapter() { } }, diagnosticsObserverActive: () => diagnosticsObserver !== undefined, + display, listenerInventory: () => Object.freeze( [...listeners.entries()] @@ -191,7 +228,9 @@ function synchronousGptAdapter() { 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) ?? []), }; } @@ -386,6 +425,7 @@ describe('browser composition', () => { }), ]), }), + slots: Object.freeze([browserSlotPlacement('slot-one')]), bids: Object.freeze([bid]), }); const composition = createTestBrowserRuntimeComposition( @@ -484,6 +524,10 @@ describe('browser composition', () => { 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, @@ -495,6 +539,7 @@ describe('browser composition', () => { }, operation: 'refresh', owner: ownerResult.value, + placement: projectedPlacement, requestClass: 'primary', slot: physicalSlot, }); @@ -529,6 +574,203 @@ describe('browser composition', () => { 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: { version: 1, releaseId, integrations: [{ id: 'gpt', required: true }] }, + 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: { version: 1, releaseId, integrations: [{ id: 'gpt', required: true }] }, + 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', @@ -629,6 +871,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'boot', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -709,6 +952,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'boot', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -852,6 +1096,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -905,6 +1150,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -992,6 +1238,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -1080,6 +1327,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -1210,6 +1458,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -1328,6 +1577,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -1411,6 +1661,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -1542,6 +1793,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -1615,6 +1867,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative, @@ -1666,6 +1919,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: true, clickGuard: false, renderGuard: false }, @@ -1735,6 +1989,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative, @@ -1786,6 +2041,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: true, clickGuard: true, renderGuard: false }, @@ -1864,6 +2120,7 @@ describe('browser composition', () => { }), ]), }), + slots: Object.freeze([browserSlotPlacement(bid.slot)]), bids: Object.freeze([bid]), }); const composition = createTestBrowserRuntimeComposition( @@ -2015,6 +2272,7 @@ describe('browser composition', () => { }), ]), }), + slots: Object.freeze([browserSlotPlacement(bid.slot)]), bids: Object.freeze([bid]), }); const composition = createTestBrowserRuntimeComposition( @@ -2168,6 +2426,7 @@ describe('browser composition', () => { auctionId: 'initial', results: [{ slot: 'slot', outcome: 'no_bid' }], }, + slots: [browserSlotPlacement('slot')], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2245,6 +2504,7 @@ describe('browser composition', () => { auctionId: 'initial', results: [{ slot: 'initial-slot', outcome: 'no_bid' }], }, + slots: [browserSlotPlacement('initial-slot')], bids: [], }; let prefix = 0; @@ -2375,6 +2635,7 @@ describe('browser composition', () => { auctionId: 'spa', results: [{ slot: 'spa-slot', outcome: 'no_bid' }], }, + slots: [browserSlotPlacement('spa-slot')], bids: [], }) ).toEqual({ status: 'committed' }); @@ -2400,6 +2661,225 @@ describe('browser composition', () => { 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: { version: 1, releaseId, integrations: [{ id: 'gpt', required: true }] }, + 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: { version: 1, releaseId, integrations: [{ id: 'gpt', required: true }] }, + 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( { @@ -2411,6 +2891,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2451,6 +2932,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2493,6 +2975,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2532,6 +3015,7 @@ describe('browser composition', () => { auctionId: 'spa', results: [{ slot: 'spa-slot', outcome: 'no_bid' }], }, + slots: [browserSlotPlacement('spa-slot')], bids: [], }) ).toEqual({ status: 'committed' }); @@ -2549,6 +3033,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2596,6 +3081,9 @@ describe('browser composition', () => { slot: `server-${index}`, })), }, + slots: Array.from({ length: serverCount }, (_, index) => + browserSlotPlacement(`server-${index}`) + ), bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2636,6 +3124,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2677,6 +3166,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, cachePolicy: { @@ -2743,6 +3233,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'boot', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2836,6 +3327,7 @@ describe('browser composition', () => { auctionId: 'initial', results: [{ slot: 'server-slot', outcome: 'no_bid' }], }, + slots: [browserSlotPlacement('server-slot')], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2990,6 +3482,7 @@ describe('browser composition', () => { auctionId: 'initial', results: [{ slot: 'server-slot', outcome: 'no_bid' }], }, + slots: [browserSlotPlacement('server-slot')], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, 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 index 2b9f4816d..bdf19a0f6 100644 --- a/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts +++ b/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts @@ -241,6 +241,7 @@ function createMaximalHarness(options: MaximalHarnessOptions = {}) { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: true, clickGuard: true, renderGuard: false }, @@ -390,6 +391,7 @@ describe('generated maximal browser runtime transaction', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: true, clickGuard: true, renderGuard: false }, 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 173e35a01..533db32d9 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -36,6 +36,16 @@ 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 { @@ -49,6 +59,7 @@ function browserProjection() { { slot: 'slot-3', outcome: 'failed', reason: 'provider_timeout' }, ], }, + slots: [browserSlot('slot-1'), browserSlot('slot-2'), browserSlot('slot-3')], bids: [ { candidateId: candidateId(), @@ -77,6 +88,7 @@ function largeAdmProjection(admLengths: number[]): BrowserAuctionProjectionV1 { candidateId: candidateId(index), })), }, + slots: admLengths.map((_, index) => browserSlot(`slot-${index}`)), bids: admLengths.map((length, index) => ({ candidateId: candidateId(index), slot: `slot-${index}`, @@ -464,22 +476,38 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { } }); + 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, @@ -508,6 +536,7 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { 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( @@ -837,6 +866,7 @@ describe('auction/parseTrustedServerAuctionResponseV1', () => { const canonical: BrowserAuctionProjectionV1 = { version: 1, auction: projected.auction, + slots: [], bids: projected.bids.map((bid) => ({ ...bid, upstreamBidId: bid.rendererReservationId, @@ -909,7 +939,10 @@ describe('auction/parseTrustedServerAuctionResponseV1', () => { expect(new TextEncoder().encode(JSON.stringify(wire)).byteLength).toBeGreaterThan( MAX_BROWSER_AUCTION_PROJECTION_BYTES ); - expect(parseBrowserAuctionProjectionV1(canonical) !== undefined).toBe(accepted); + expect( + new TextEncoder().encode(JSON.stringify(canonical)).length <= + MAX_BROWSER_AUCTION_PROJECTION_BYTES + ).toBe(accepted); expect(parseTrustedServerAuctionResponseV1(wire) !== undefined).toBe(accepted); } }); 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 a46efa57c..b9cdf8324 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,100 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; - -import type { AuctionBidData, AuctionSlot, LegacyTsjsApi } 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); + +function boot() { + return { + abi: 1, + releaseId: RELEASE, + manifest: { version: 1, releaseId: RELEASE, integrations: [] }, + 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 } }, + }; +} + +describe('core production bootstrap', () => { beforeEach(async () => { await vi.resetModules(); document.body.innerHTML = ''; - delete window.tsjs; + delete (window as unknown as { tsjs?: unknown }).tsjs; }); - afterEach(() => { - global.fetch = ORIGINAL_FETCH; - }); - - it('initializes tsjs API with expected surface', async () => { - await import('../../src/core/index'); - const api = window.tsjs as LegacyTsjsApi; - 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 import('../../src/composition/index'); + 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 LegacyTsjsApi; - 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 import('../../src/composition/index'); + 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 LegacyTsjsApi; - - await import('../../src/core/index'); - - expect(window.tsjs!.adSlots).toEqual([{ id: 'pre-injected' }]); - expect(window.tsjs!.bids).toEqual({ 'pre-injected': { hb_pb: '1.00' } }); - }); - - it('flushes queued callbacks that existed before initialization', async () => { - const callback = vi.fn(function (this: LegacyTsjsApi) { - expect(this).toBe(window.tsjs); - }); - window.tsjs = { que: [callback] as Array<() => void> } as LegacyTsjsApi; - - 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 LegacyTsjsApi; - 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 LegacyTsjsApi; - - 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'); - }); - - 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 LegacyTsjsApi; - - expect(api.requestAds).toBe(requestAds); + it('fails closed when the transient integration-config transport is not plain data', async () => { + const preload = { + boot: boot(), + que: [], + _integrationConfig: new (class Config {})(), + }; + (window as unknown as { tsjs?: unknown }).tsjs = preload; + + await import('../../src/composition/index'); + await vi.waitFor(() => + expect((window as unknown as { tsjs?: TsjsApi }).tsjs?._internal.state).toBe('fallback') + ); + + const api = (window as unknown as { tsjs: TsjsApi }).tsjs; + expect(api._internal).toMatchObject({ state: 'fallback', reason: 'abi_mismatch' }); + expect(api).not.toHaveProperty('diagnostics'); }); }); 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 517e30e55..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 @@ -4,8 +4,8 @@ import { FIRST_PARTY_CLICK, MUTATED_CLICK, PROXY_RESPONSE, + activateCreativeRuntime, disposeImportedCreativeModule, - importCreativeModule, } from './helpers'; const ORIGINAL_FETCH = global.fetch; @@ -67,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); @@ -94,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); @@ -136,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); @@ -176,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(); @@ -225,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); @@ -317,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(); @@ -345,7 +345,7 @@ describe('creative/click.ts', () => { document.body.appendChild(anchor); try { - await importCreativeModule(); + await activateCreativeRuntime(); anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); await Promise.resolve(); await vi.runAllTimersAsync(); @@ -372,7 +372,7 @@ describe('creative/click.ts', () => { document.body.appendChild(anchor); try { - await importCreativeModule(); + await activateCreativeRuntime(); anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); await Promise.resolve(); await vi.runAllTimersAsync(); 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 1ce8a069c..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,7 +17,7 @@ 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'; let disposeLastImportedCreative: (() => void) | undefined; @@ -27,19 +27,33 @@ export function disposeImportedCreativeModule(): void { dispose?.(); } -export async function importCreativeModule(config?: TsCreativeConfig): Promise { +export async function activateCreativeRuntime( + config: Partial> = {} +): Promise { disposeImportedCreativeModule(); - const globalRef = globalThis as { - __ts_creative_installed?: boolean; - tsCreativeConfig?: TsCreativeConfig; - }; - delete globalRef.__ts_creative_installed; - if (config) { - globalRef.tsCreativeConfig = config; - } - const creative = await import('../../../src/integrations/creative/index'); - disposeLastImportedCreative = creative.disposeGuards; - if (config) { - delete globalRef.tsCreativeConfig; - } + 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 8319a1602..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,6 +1,6 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { disposeImportedCreativeModule, importCreativeModule, waitForExpect } from './helpers'; +import { activateCreativeRuntime, disposeImportedCreativeModule, waitForExpect } from './helpers'; describe('creative/iframe.ts', () => { const ORIGINAL_FETCH = global.fetch; @@ -25,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'; @@ -44,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'; 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 525bb66ad..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,6 +1,6 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { disposeImportedCreativeModule, importCreativeModule, waitForExpect } from './helpers'; +import { activateCreativeRuntime, disposeImportedCreativeModule, waitForExpect } from './helpers'; const ORIGINAL_FETCH = global.fetch; @@ -25,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'; @@ -44,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'; 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 0ed24dae3..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ /dev/null @@ -1,3932 +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 { - BidRenderSourceV1, - BrowserAuctionBidV1, - GptSlotHandoff, - LegacyTsjsApi, -} 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, - }; -} - -describe('prepareTrustedServerGptTargetingV1', () => { - function projectedBid(renderSource: BidRenderSourceV1): BrowserAuctionBidV1 { - return { - candidateId: 'AAAAAAAAAAAA', - slot: 'slot-1', - provider: 'prebid', - upstreamBidId: 'upstream-bid', - cpm: 1.25, - currency: 'USD', - targeting: { hb_bidder: 'example', hb_pb: '1.25' }, - rendererReservationId: 'r1_AAAAAAAAAAAAAAAAAAAAAA', - renderSource, - }; - } - - it('uses the exact renderer reservation as hb_adid for APS, ADM, and cache', async () => { - const { prepareTrustedServerGptTargetingV1 } = - await import('../../../src/integrations/gpt/index'); - const sources: BidRenderSourceV1[] = [ - apsRenderer(), - { type: 'adm', version: 1, adm: '
ad
', width: 300, height: 250 }, - { - type: 'cache', - version: 1, - cacheId: 'f47447a0-b759-4f2f-9887-af458b79b570', - fetchUrl: 'https://cache.example/pbc/v1/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570', - width: 300, - height: 250, - }, - ]; - - for (const source of sources) { - const bid = projectedBid(source); - const targeting = prepareTrustedServerGptTargetingV1(bid); - expect(targeting).toEqual({ - hb_adid: 'r1_AAAAAAAAAAAAAAAAAAAAAA', - hb_bidder: 'example', - hb_pb: '1.25', - }); - expect(bid.targeting).toEqual({ hb_bidder: 'example', hb_pb: '1.25' }); - } - }); - - it('rejects malformed reservations without truncating or falling back to other ids', async () => { - const { prepareTrustedServerGptTargetingV1 } = - await import('../../../src/integrations/gpt/index'); - const malformed = projectedBid({ - type: 'cache', - version: 1, - cacheId: 'f47447a0-b759-4f2f-9887-af458b79b570', - fetchUrl: 'https://cache.example/pbc/v1/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570', - width: 300, - height: 250, - }); - malformed.rendererReservationId = `r1_${'A'.repeat(23)}`; - malformed.upstreamBidId = 'fallback-upstream'; - - expect(prepareTrustedServerGptTargetingV1(malformed)).toBeUndefined(); - expect(malformed.rendererReservationId).toHaveLength(26); - - const prepopulated = projectedBid(apsRenderer()); - prepopulated.targeting.hb_adid = 'forbidden-fallback'; - expect(prepareTrustedServerGptTargetingV1(prepopulated)).toBeUndefined(); - }); -}); - -// 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 legacy API (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 legacy API shape. -type TestGptSlotHandoff = Omit & { formats: number[][] }; -type TestTsjsApi = Omit, 'gptSlotHandoffs'> & { - gptSlotHandoffs?: Record | undefined; -}; -type TestWindow = Omit & { - googletag?: unknown; - apstag?: { setDisplayBids?: () => void }; - tsjs?: TestTsjsApi; -}; - -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("ad'prefix-real")?.remove(); - document.querySelectorAll('[data-responsive-slot-test]').forEach((element) => element.remove()); - }); - - 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 sent = rendererPost.mock.calls[0]![0] as { nonce: string }; - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: outerFrame.contentWindow, - }) - ); - await expect(rendered).resolves.toBeUndefined(); - outerFrame.remove(); - } finally { - delete dynamicWindow.render; - } - beaconSpy.mockRestore(); - }); - - it('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 partially resize when the authenticated wrapper is already expanded', async () => { - (window as TestWindow).tsjs!.bids!.homepage_header = { - hb_adid: 'expanded-wrapper-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 = '2px'; - slot.style.height = '1px'; - frame.width = '1'; - frame.height = '1'; - frame.style.width = '1px'; - frame.style.height = '1px'; - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'expanded-wrapper-ad-id' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(frame.style.width).toBe('1px'); - expect(frame.style.height).toBe('1px'); - expect(slot.style.width).toBe('2px'); - expect(slot.style.height).toBe('1px'); - }); - - 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('calls stopImmediatePropagation and fetches PBS Cache for a TS bid', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - 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 fakePort = { postMessage: (s: string) => portMessages.push(s) }; - 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(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('declines to render when the PBS Cache response carries no adm', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - // 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(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 () => { - 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); - 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 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(); - 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/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index 79c86fef0..202402f8a 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -291,6 +291,15 @@ describe('generated terminal bootstrap fallback proposal', () => { auctionId: 'boot', results: [{ slot: 'known', outcome: 'no_bid' }], }, + slots: [ + { + slot: 'known', + gamUnitPath: '/123/known', + divId: 'known', + formats: [[300, 250]], + targeting: {}, + }, + ], bids: [], }, }, @@ -347,6 +356,7 @@ describe('generated terminal bootstrap fallback proposal', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], bids: [], }, }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts deleted file mode 100644 index d50b697f4..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Mock } from 'vitest'; - -import type { LegacyTsjsApi } from '../../../src/core/types'; - -// We import installGptShim dynamically so each test can control whether the -// GPT enable flag is present before module evaluation. - -async function importGuardModule() { - return import('../../../src/integrations/gpt/script_guard'); -} - -type GptWindow = Window & { - googletag?: { - // The shim marks the queue it patched; `push` itself comes from `Array`, - // which GPT replaces with its own execute-immediately implementation. - cmd: Array<() => void> & { __tsPushed?: boolean }; - _loaded_?: boolean; - }; -}; - -describe('GPT shim – patchCommandQueue', () => { - let win: GptWindow; - let installGptShim: () => boolean; - - beforeEach(async () => { - // Reset any prior state - const guard = await importGuardModule(); - guard.resetGuardState(); - win = window as GptWindow; - delete win.googletag; - - // Dynamic import to get a fresh reference (the module self-init already - // ran at first import, but installGptShim is idempotent via the guard). - const mod = await import('../../../src/integrations/gpt/index'); - installGptShim = mod.installGptShim; - }); - - afterEach(async () => { - const guard = await importGuardModule(); - guard.resetGuardState(); - delete (window as GptWindow).googletag; - }); - - it('preserves googletag.cmd array identity', () => { - const originalCmd: Array<() => void> = []; - win.googletag = { cmd: originalCmd }; - - installGptShim(); - - expect(win.googletag!.cmd).toBe(originalCmd); - }); - - it('preserves custom cmd.push when GPT is already loaded', () => { - // Simulate GPT's loaded state: cmd.push executes callbacks immediately. - const executed: string[] = []; - const cmd: Array<() => void> = []; - const gptCustomPush = (...fns: Array<() => void>): number => { - // GPT's custom push executes immediately and appends to the array. - for (const fn of fns) { - fn(); - cmd[cmd.length] = fn; - } - return cmd.length; - }; - cmd.push = gptCustomPush; - - win.googletag = { cmd, _loaded_: true }; - - installGptShim(); - - // Push a new callback after patching — it should still delegate to - // GPT's custom push (which executes immediately). - win.googletag!.cmd.push(() => { - executed.push('post-patch'); - }); - - expect(executed).toContain('post-patch'); - }); - - it('wraps callbacks pushed after patching with error handling', () => { - win.googletag = { cmd: [] }; - - installGptShim(); - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - // Push a callback that throws - win.googletag!.cmd.push(() => { - throw new Error('test error'); - }); - - // The wrapped callback should be in the queue — execute it. - const wrappedFn = win.googletag!.cmd[win.googletag!.cmd.length - 1]; - expect(() => wrappedFn!()).not.toThrow(); - - errorSpy.mockRestore(); - }); - - it('re-wraps already-queued pending callbacks in place', () => { - const callOrder: string[] = []; - const pending = [() => callOrder.push('first'), () => callOrder.push('second')]; - - win.googletag = { cmd: pending }; - - installGptShim(); - - // The pending callbacks should have been wrapped in place. - // Execute them — they should not throw even if one of them did. - for (const fn of win.googletag!.cmd) { - fn(); - } - - expect(callOrder).toEqual(['first', 'second']); - }); - - it('handles pending callback that throws without breaking the queue', () => { - const callOrder: string[] = []; - const pending = [ - () => { - throw new Error('boom'); - }, - () => callOrder.push('after-error'), - ]; - - win.googletag = { cmd: pending }; - - installGptShim(); - - // Execute all wrapped callbacks — the error should be caught. - for (const fn of win.googletag!.cmd) { - expect(() => fn()).not.toThrow(); - } - - expect(callOrder).toEqual(['after-error']); - }); - - it('is idempotent — calling installGptShim twice does not double-wrap', () => { - const calls: number[] = []; - win.googletag = { cmd: [] }; - - installGptShim(); - const pushAfterFirst = win.googletag!.cmd.push; - - installGptShim(); - const pushAfterSecond = win.googletag!.cmd.push; - - // The push function should be the same reference (not re-wrapped). - expect(pushAfterSecond).toBe(pushAfterFirst); - - // Push a callback and verify it only executes once (not double-wrapped). - win.googletag!.cmd.push(() => calls.push(1)); - const fn = win.googletag!.cmd[win.googletag!.cmd.length - 1]; - fn!(); - - expect(calls).toEqual([1]); - }); - - it('creates googletag.cmd if it does not exist', () => { - // No googletag at all on window. - delete win.googletag; - - installGptShim(); - - expect(win.googletag).toBeDefined(); - expect(Array.isArray(win.googletag!.cmd)).toBe(true); - }); -}); - -describe('GPT – installSlimPrebidLoader', () => { - type SlimWindow = Window & { __tsjs_slim_prebid_url?: string }; - - afterEach(() => { - delete (window as SlimWindow).__tsjs_slim_prebid_url; - }); - - it('is a no-op when __tsjs_slim_prebid_url is not set', async () => { - const { installSlimPrebidLoader } = await import('../../../src/integrations/gpt/index'); - const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); - installSlimPrebidLoader(); - expect(addEventListenerSpy).not.toHaveBeenCalledWith('load', expect.any(Function)); - addEventListenerSpy.mockRestore(); - }); - - it('appends a deferred script tag when __tsjs_slim_prebid_url is set and load fires', async () => { - (window as SlimWindow).__tsjs_slim_prebid_url = 'https://cdn.example.com/slim-prebid.js'; - const { installSlimPrebidLoader } = await import('../../../src/integrations/gpt/index'); - - installSlimPrebidLoader(); - - // Simulate the window load event. - window.dispatchEvent(new Event('load')); - - const scripts = Array.from(document.querySelectorAll('script[defer]')); - const injected = scripts.find( - (s) => (s as HTMLScriptElement).src === 'https://cdn.example.com/slim-prebid.js' - ); - expect(injected).toBeDefined(); - - // Clean up - injected?.parentNode?.removeChild(injected); - }); - - it('module init calls installSlimPrebidLoader — script injected when URL is preset', async () => { - vi.resetModules(); - (window as SlimWindow).__tsjs_slim_prebid_url = 'https://cdn.example.com/slim-prebid-init.js'; - - await import('../../../src/integrations/gpt/index'); - window.dispatchEvent(new Event('load')); - - const scripts = Array.from(document.querySelectorAll('script[defer]')); - const injected = scripts.find( - (s) => (s as HTMLScriptElement).src === 'https://cdn.example.com/slim-prebid-init.js' - ); - expect(injected).toBeDefined(); - - injected?.parentNode?.removeChild(injected); - }); -}); - -describe('GPT – installTsAdInit', () => { - // GPT slot mock: setTargeting/clearTargeting are chainable, so both return - // the slot itself. - interface MockGptSlot { - getSlotElementId: Mock<() => string>; - getTargeting: Mock<(key: string) => string[]>; - setTargeting: Mock<(key: string, value: string | string[]) => MockGptSlot>; - clearTargeting: Mock<(key?: string) => MockGptSlot>; - } - - // Minimal pubads surface adInit() drives. - interface MockPubAds { - getSlots: () => MockGptSlot[]; - enableSingleRequest: () => void; - addEventListener: (event: string, fn: (e: unknown) => void) => void; - refresh: (slots?: MockGptSlot[]) => void; - } - - interface MockGoogleTag { - cmd: Array<() => void>; - pubads: () => MockPubAds; - defineSlot: Mock; - destroySlots: Mock; - enableServices: Mock; - } - - // `tsjs` is declared globally as the full legacy API; `Omit` drops it from - // `Window` so the fixture below only has to satisfy the fields it sets. - type AdInitWindow = Omit & { - tsjs?: Partial; - googletag?: MockGoogleTag; - }; - - beforeEach(() => { - document.body.innerHTML = ''; - delete (window as AdInitWindow).tsjs; - delete (window as AdInitWindow).googletag; - }); - - afterEach(() => { - document.body.innerHTML = ''; - delete (window as AdInitWindow).tsjs; - delete (window as AdInitWindow).googletag; - }); - - it('clears stale TS-managed targeting before applying a new route to a reused GPT slot', async () => { - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - const slotTargeting = new Map([ - ['hb_pb', ['1.20']], - ['hb_bidder', ['kargo']], - ['hb_adid', ['old-ad']], - ['hb_cache_host', ['cache.example.com']], - ['hb_cache_path', ['/cache']], - ['ts_initial', ['1']], - ['pos', ['old-pos']], - ]); - const gptSlot: MockGptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn((key: string) => slotTargeting.get(key) ?? []), - setTargeting: vi.fn((key: string, value: string | string[]) => { - slotTargeting.set(key, Array.isArray(value) ? value : [value]); - return gptSlot; - }), - clearTargeting: vi.fn((key?: string) => { - if (key) { - slotTargeting.delete(key); - } else { - slotTargeting.clear(); - } - return gptSlot; - }), - }; - const pubads = { - getSlots: vi.fn(() => [gptSlot]), - enableSingleRequest: vi.fn(), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - const cmd: Array<() => void> = []; - cmd.push = (...callbacks: Array<() => void>) => { - callbacks.forEach((callback) => callback()); - return cmd.length; - }; - - document.body.innerHTML = '
'; - (window as AdInitWindow).googletag = { - cmd, - pubads: () => pubads, - defineSlot: vi.fn(), - destroySlots: vi.fn(), - enableServices: vi.fn(), - }; - (window as AdInitWindow).tsjs = { - prevSlotTargetingKeys: { - 'div-ad-homepage-header': ['pos'], - }, - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [[728, 90]], - targeting: { zone: 'homepage' }, - }, - ], - bids: {}, - }; - - installTsAdInit(); - (window as AdInitWindow).tsjs!.adInit!(); - - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_bidder'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_host'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('pos'); - expect(slotTargeting.get('hb_pb')).toBeUndefined(); - expect(slotTargeting.get('hb_bidder')).toBeUndefined(); - expect(slotTargeting.get('hb_adid')).toBeUndefined(); - expect(slotTargeting.get('hb_cache_host')).toBeUndefined(); - expect(slotTargeting.get('hb_cache_path')).toBeUndefined(); - expect(slotTargeting.get('pos')).toBeUndefined(); - expect(slotTargeting.get('zone')).toEqual(['homepage']); - expect(slotTargeting.get('ts_initial')).toEqual(['1']); - }); -}); - -describe('GPT shim – runtime gating', () => { - type GatedWindow = Window & { - __tsjs_gpt_enabled?: boolean; - googletag?: { cmd: Array<() => void> }; - // Activation hook the module registers; the tests only assert its typeof. - __tsjs_installGptShim?: unknown; - }; - - let win: GatedWindow; - - beforeEach(async () => { - const guard = await importGuardModule(); - guard.resetGuardState(); - win = window as GatedWindow; - delete win.googletag; - delete win.__tsjs_gpt_enabled; - }); - - afterEach(async () => { - const guard = await importGuardModule(); - guard.resetGuardState(); - delete (window as GatedWindow).googletag; - delete (window as GatedWindow).__tsjs_gpt_enabled; - delete (window as GatedWindow).__tsjs_installGptShim; - }); - - it('installs the shim when activation function is called (simulates server inline script)', async () => { - const guard = await importGuardModule(); - const { installGptShim } = await import('../../../src/integrations/gpt/index'); - - // Simulate what the server-injected inline script does: - // set the flag then call the activation function. - win.__tsjs_gpt_enabled = true; - installGptShim(); - - expect(guard.isGuardInstalled()).toBe(true); - expect(win.googletag).toBeDefined(); - }); - - it('registers __tsjs_installGptShim on window after import', async () => { - vi.resetModules(); - await import('../../../src/integrations/gpt/index'); - - expect(typeof (window as GatedWindow).__tsjs_installGptShim).toBe('function'); - }); - - it('auto-installs the shim when the enable flag is set before import', async () => { - vi.resetModules(); - win.__tsjs_gpt_enabled = true; - - const guard = await importGuardModule(); - await import('../../../src/integrations/gpt/index'); - - expect(guard.isGuardInstalled()).toBe(true); - expect(win.googletag).toBeDefined(); - }); - - it('does not install the shim when only imported (no explicit activation)', async () => { - // Reset modules so the next dynamic import re-evaluates the module. - vi.resetModules(); - - const guard = await importGuardModule(); - // Import a fresh copy — the module should register the activation - // function on `window` but NOT call `installGptShim()` on its own. - await import('../../../src/integrations/gpt/index'); - - // Assert immediately — the guard must not be installed because the - // module only registers `__tsjs_installGptShim`, it does not auto-init. - expect(guard.isGuardInstalled()).toBe(false); - expect(win.googletag).toBeUndefined(); - }); -}); - -describe('GPT debug ADM iframe hardening', () => { - it('sandbox token list omits allow-same-origin', async () => { - const mod = await import('../../../src/integrations/gpt/index'); - - expect(mod.ADM_IFRAME_SANDBOX).toContain('allow-scripts'); - // allow-scripts + allow-same-origin on srcdoc content removes the - // sandbox's origin isolation — the pair must never be reintroduced. - expect(mod.ADM_IFRAME_SANDBOX).not.toContain('allow-same-origin'); - }); - - it('safeAdmIframeSrc accepts http(s), relative, and protocol-relative URLs', async () => { - const { safeAdmIframeSrc } = await import('../../../src/integrations/gpt/index'); - - expect(safeAdmIframeSrc('https://ads.example.com/creative')).toBe( - 'https://ads.example.com/creative' - ); - expect(safeAdmIframeSrc('http://ads.example.com/creative')).toBe( - 'http://ads.example.com/creative' - ); - expect(safeAdmIframeSrc('//ads.example.com/creative')).toBe('https://ads.example.com/creative'); - expect(safeAdmIframeSrc('/first-party/creative?sig=abc')).toBe('/first-party/creative?sig=abc'); - }); - - it('safeAdmIframeSrc rejects script-executing and opaque schemes', async () => { - const { safeAdmIframeSrc } = await import('../../../src/integrations/gpt/index'); - - expect(safeAdmIframeSrc('javascript:alert(1)')).toBeUndefined(); - expect(safeAdmIframeSrc('data:text/html,')).toBeUndefined(); - expect(safeAdmIframeSrc('blob:https://example.com/uuid')).toBeUndefined(); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts index 57fed654c..8dbe0f0fe 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -534,6 +534,13 @@ describe('ordered GPT winner publication', () => { 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({ @@ -547,6 +554,7 @@ describe('ordered GPT winner publication', () => { }), ]), }), + slots: Object.freeze([placement]), bids: Object.freeze([bid]), }); expect(harness.navigation.installAuctionProjection(projection)).toBe(true); @@ -567,6 +575,7 @@ describe('ordered GPT winner publication', () => { 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: () => { @@ -578,6 +587,7 @@ describe('ordered GPT winner publication', () => { 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 }), @@ -636,6 +646,7 @@ describe('ordered GPT winner publication', () => { navigation: harness.navigation, operation: 'refresh', owner: harness.primaryOwner, + placement, pucBridge, requestClass: 'primary', reservations, @@ -671,6 +682,7 @@ describe('ordered GPT winner publication', () => { 'slot:validate', 'target:hb_adid', 'target:hb_bidder', + 'target:pos', 'slot:validate', 'bridge', 'request', @@ -679,6 +691,7 @@ describe('ordered GPT winner publication', () => { new Map([ ['hb_adid', [RESERVATION_ID]], ['hb_bidder', ['trusted']], + ['pos', ['top']], ]) ); publication.bridgeArtifact()?.dispose(); @@ -745,6 +758,7 @@ describe('ordered GPT winner publication', () => { 'slot:validate', 'target:hb_adid', 'target:hb_bidder', + 'target:pos', 'slot:validate', ]); expect(publication.values.size).toBe(0); @@ -795,6 +809,7 @@ describe('ordered GPT winner publication', () => { 'slot:validate', 'target:hb_adid', 'target:hb_bidder', + 'target:pos', 'slot:validate', 'bridge', 'request', 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 07bfde6f5..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 { LegacyTsjsApi } from '../../../src/core/types'; - -type TestWindow = Window & { - googletag?: unknown; - tsjs?: LegacyTsjsApi; -}; - -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 139edcd34..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 { LegacyTsjsApi } from '../../../src/core/types'; - -type TestWindow = Window & { - googletag?: unknown; - tsjs?: LegacyTsjsApi; -}; - -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 (auction kill switch or consent denial) returns - // no slots. With no prior TS state to sweep, the hook must not call adInit() - // so a consent-denied navigation cannot activate the publisher's GPT setup. - 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/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index f970ebe49..cb800e2d1 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -3878,96 +3878,3 @@ describe('prebid/client-side bidders', () => { 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/sourcepoint/index.test.ts b/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts index e7906f111..f23e86c25 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'; +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(); }); }); @@ -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/kernel/fallback.test.ts b/crates/trusted-server-js/lib/test/kernel/fallback.test.ts index be6c07539..76eae82d5 100644 --- a/crates/trusted-server-js/lib/test/kernel/fallback.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/fallback.test.ts @@ -17,6 +17,7 @@ function boot(creative: unknown) { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative, diff --git a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts index 9af56233e..d03470155 100644 --- a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -15,6 +15,16 @@ function boot(results: readonly object[] = []) { 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 }, @@ -837,6 +847,7 @@ describe('Runtime bootstrap owner', () => { ).toEqual({ version: 1, auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], bids: [], }); }); @@ -988,6 +999,7 @@ describe('Runtime bootstrap owner', () => { ).toEqual({ version: 1, auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], bids: [], }); } @@ -1017,6 +1029,7 @@ describe('Runtime bootstrap owner', () => { ).toEqual({ version: 1, auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], bids: [], }); }); 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 72e34774d..d01b2549e 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 @@ -467,158 +467,4 @@ describe('external bundle + served shim evaluated together', () => { adapter.dispose(); dom.window.close(); }, 60_000); - - it('populates the public API, installs the shim exactly once, and routes an /auction request', async () => { - 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 - ); - } - }; - pageWindow.Headers = Headers; - pageWindow.Response = Response; - pageWindow.AbortController = AbortController; - if (!('isSecureContext' in pageWindow)) { - pageWindow.isSecureContext = true; - } - - // Mirror the server's head-injected state, which always precedes the - // bundle script in document order. - pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); - pageWindow.__tsjs_prebid = { clientSideBidders: [] }; - - pageWindow.eval(bundleCode); - - expect(typeof pageWindow.pbjs.requestBids).toBe('function'); - expect(typeof pageWindow.pbjs.registerBidAdapter).toBe('function'); - expect(pageWindow.__tsjs_prebid_bundle).toBeUndefined(); - expect(pageWindow.__tsjsPrebidShimInstalled).toBeUndefined(); - const artifactDescriptor = Object.getOwnPropertyDescriptor( - pageWindow.pbjs, - '__trustedServerArtifactV1' - ); - expect(artifactDescriptor).toMatchObject({ - enumerable: false, - writable: false, - configurable: false, - }); - expect(artifactDescriptor.value).toEqual( - expect.objectContaining({ - abi: 1, - artifactReleaseId: artifactManifest.artifactReleaseId, - prebidVersion: '10.26.0', - }) - ); - expect([...artifactDescriptor.value.bidderCodes]).toEqual(['adf', 'adform', 'adformOpenRTB']); - expect([...artifactDescriptor.value.bidderAliases]).toEqual([ - { code: 'adform', moduleStem: 'adf' }, - { code: 'adformOpenRTB', moduleStem: 'adf' }, - ]); - expect([...artifactDescriptor.value.userIdModules]).toEqual([ - { - moduleName: 'sharedIdSystem', - configNames: ['pubCommonId', 'sharedId'], - eidSources: ['pubcid.org'], - }, - ]); - expect(Object.isFrozen(artifactDescriptor.value)).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.moduleStems)).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.bidderCodes)).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.bidderAliases)).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.bidderAliases[0])).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.userIdModules)).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0])).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0].configNames)).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0].eidSources)).toBe(true); - const adapter = createBrowserPrebidAdapter(pageWindow); - expect(adapter.bindingStatus()).toBe('present'); - adapter.dispose(); - - // 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(trustedServerRegistrations).toHaveLength(1); - expect(pageWindow.pbjs.requestBids).toBe(wrappedRequestBids); - expect(pageWindow.__tsjsPrebidShimInstalled).toBe(true); - - // Drive one real auction through the wrapped requestBids and assert the - // transformed request reaches /auction. - const slot = pageWindow.document.createElement('div'); - slot.id = 'ad-slot-1'; - pageWindow.document.body.appendChild(slot); - - pageWindow.pbjs.requestBids({ - adUnits: [ - { - code: 'ad-slot-1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - bids: [{ bidder: 'appnexus', params: { placementId: 1 } }], - }, - ], - timeout: 1000, - }); - - const requestUrl = (resource) => - typeof resource === 'string' ? resource : String(resource?.url ?? resource); - - await vi.waitFor( - () => { - expect( - fetchSpy.mock.calls.some(([resource]) => requestUrl(resource).includes('/auction')) - ).toBe(true); - }, - { timeout: 10_000 } - ); - - const [resource, init] = fetchSpy.mock.calls.find(([target]) => - requestUrl(target).includes('/auction') - ); - const body = init?.body ?? (typeof resource === 'object' ? await resource.text() : undefined); - const method = init?.method ?? resource?.method; - expect(method).toBe('POST'); - const payload = JSON.parse(body); - const adUnit = payload.adUnits[0]; - expect(adUnit.code).toBe('ad-slot-1'); - // 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 } }); - - dom.window.close(); - }, 60_000); }); diff --git a/crates/trusted-server-js/lib/test/services/projections.test.ts b/crates/trusted-server-js/lib/test/services/projections.test.ts index 818583c0f..601e52ffc 100644 --- a/crates/trusted-server-js/lib/test/services/projections.test.ts +++ b/crates/trusted-server-js/lib/test/services/projections.test.ts @@ -7,6 +7,7 @@ import { createPageBidsController, prepareInitialAuctionProjection, type PreparedProjectionSlots, + type ProjectionSlotRegistration, type ProjectionSlotRegistry, } from '../../src/services/projections'; @@ -33,6 +34,13 @@ function projection(slots: readonly string[], auctionId = 'page-bids') { 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: [], }; } @@ -50,13 +58,13 @@ class SlotLedger implements ProjectionSlotRegistry { public prepareProjectionSlots( ownerGeneration: object, - slots: readonly string[], + slots: readonly ProjectionSlotRegistration[], maximumActiveSlots: number ): PreparedProjectionSlots | undefined { this.prepareCalls += 1; if ( this.slots.size + slots.length > maximumActiveSlots || - slots.some((slot) => this.slots.has(slot)) + slots.some((slot) => this.slots.has(slot.registeredSlotId)) ) { return undefined; } @@ -65,13 +73,13 @@ class SlotLedger implements ProjectionSlotRegistry { ownerGeneration, commit: () => { this.commitHook?.(); - for (const slot of slots) this.slots.add(slot); + 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); + for (const slot of slots) this.slots.delete(slot.registeredSlotId); committed = false; }, }); @@ -107,6 +115,36 @@ describe('initial auction projection', () => { }); 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( diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 54485e8b6..1f157eb6b 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -74,6 +74,7 @@ function createGptHarness( 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 }), @@ -87,6 +88,7 @@ function createGptHarness( 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(); diff --git a/crates/trusted-server-js/lib/vitest.config.ts b/crates/trusted-server-js/lib/vitest.config.ts index 446c3146c..746860139 100644 --- a/crates/trusted-server-js/lib/vitest.config.ts +++ b/crates/trusted-server-js/lib/vitest.config.ts @@ -1,10 +1,22 @@ +import fs from 'node:fs'; import path from 'node:path'; import { configDefaults, defineConfig } from 'vitest/config'; +const integrationIds = fs + .readdirSync(path.resolve(import.meta.dirname, 'src/integrations'), { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + fs.existsSync(path.resolve(import.meta.dirname, 'src/integrations', entry.name, 'index.ts')) + ) + .map((entry) => entry.name) + .sort(); + export default defineConfig({ define: { __TSJS_EMBEDDED_RELEASE_ID_V1__: JSON.stringify('a'.repeat(64)), + __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: JSON.stringify(integrationIds), }, resolve: { alias: { diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index f83552b8a..a646f6743 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -2559,7 +2559,11 @@ implementation change. **Files:** - Modify: `crates/trusted-server-js/lib/src/core/index.ts` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Modify: `crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts` - Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/src/services/projections.ts` +- Modify: `crates/trusted-server-js/lib/src/services/slots.ts` - Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` - Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` - Modify: `crates/trusted-server-js/lib/src/integrations/creative/index.ts` @@ -2577,11 +2581,20 @@ implementation change. - Modify: `crates/trusted-server-core/src/tsjs.rs` - Modify: `crates/trusted-server-core/src/auction/endpoints.rs` - Modify: `crates/trusted-server-core/src/auction/formats.rs` +- Modify: `crates/trusted-server-core/src/auction/types.rs` - Modify: `crates/trusted-server-core/src/integrations/registry.rs` +- Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Modify: `crates/trusted-server-core/src/integrations/mod.rs` +- Modify: `crates/trusted-server-core/src/platform/mod.rs` +- Modify: `crates/trusted-server-core/src/platform/types.rs` - Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` - Modify: `crates/trusted-server-adapter-axum/src/app.rs` +- Modify: `crates/trusted-server-adapter-axum/src/main.rs` - Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/src/lib.rs` - Modify: `crates/trusted-server-adapter-spin/src/app.rs` +- Modify: `crates/trusted-server-adapter-spin/src/lib.rs` - Modify: `crates/trusted-server-core/src/html_processor.rs` - Modify: `crates/trusted-server-core/src/integrations/prebid.rs` - Modify: `crates/trusted-server-core/src/integrations/didomi.rs` @@ -2590,6 +2603,12 @@ implementation change. - Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics.rs` - Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js` - Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Modify: `crates/trusted-server-js/lib/test/core/auction.test.ts` +- Modify: `crates/trusted-server-js/lib/test/services/projections.test.ts` +- Modify: `crates/trusted-server-js/lib/test/services/slots.test.ts` +- Modify: `crates/trusted-server-js/lib/test/adapters/googletag.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` - [ ] **Step 1: Complete the pre-switch checklist with no production-wiring changes staged.** The atomic switch is allowed to flip wiring only after every behavior suite @@ -2648,6 +2667,19 @@ implementation change. - point `/auction`, initial HTML, and page-bids production emitters at the already-tested exact decision/projection serializers and boot-script fragments, including the preimplemented `tsjs:bids-script` mark; + - carry one exact ordered placement record for every initial/page-bids decision, + reject missing/extra/out-of-order placement coverage in the browser parser, and + keep only the direct `/auction` serializer's internal `slots:[]` exception; + - have the sole composition resolve each placement, adopt exactly one existing + publisher GPT slot or transactionally define/adopt one TS slot, merge static then + bid targeting with runtime-owned `hb_adid`, and publish both initial and committed + SPA winners through the same GPT/PUC lifecycle. Cover responsive-prefix ambiguity, + stale candidate destruction, publisher refresh versus TS display, attributable + empty-GAM direct fallback, and page-bids alias registration; + - preserve rc/july SPA route semantics in that composition: pathname-plus-query + identity across push/replace/pop, same-route suppression, stale-response + inertness, and rollback to the last committed path after a current failure so an + identical route can retry; - make the sole browser composition root construct the already-tested runtime, services, adapters, integration modules, fallback, and queue handoff, then have each thin integration `index.ts` delegate to that composition without retaining a @@ -2655,7 +2687,9 @@ implementation change. - switch generated release/manifest/config/bootstrap emission and the independently built pure Prebid 10.26.0 artifact to those already-tested entry points; and - register the already-tested versioned APS renderer and live unversioned runner - proxy through all four adapter dispatchers while preserving the negative routes. + proxy in the production registry and pre-router entry points of all four adapters, + preserving exact response headers and the negative routes before auth, generic + finalization, EC, integration filters, or publisher fallback can run. The switch is a hard cutover: add no selector, dual manifest, compatibility alias, protocol autodetection, or fallback to old behavior. The old implementation may diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index bc1cfa365..a20e9c8c7 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -920,6 +920,13 @@ interface AuctionDecisionSetV1 { interface BrowserAuctionProjectionV1 { version: 1 auction: AuctionDecisionSetV1 + slots: Array<{ + slot: string + gamUnitPath: string + divId: string + formats: Array + targeting: Record + }> bids: Array<{ candidateId: string slot: string @@ -936,11 +943,11 @@ interface BrowserAuctionProjectionV1 { `BrowserAuctionProjectionV1` is exact, deny-unknown, and bounded before any slot, reservation, targeting, or bid mutation. Its canonical UTF-8 JSON is at most -`MAX_BROWSER_AUCTION_PROJECTION_BYTES = 8 * 1024 * 1024`; `auction.results` and -`bids` each contain at most 256 entries; and all objects are plain own-data objects +`MAX_BROWSER_AUCTION_PROJECTION_BYTES = 8 * 1024 * 1024`; `auction.results`, `slots`, +and `bids` each contain at most 256 entries; and all objects are plain own-data objects with no accessors. Canonical serialization uses the interface field order shown, -request order for results, matching result order for bids, lexically sorted targeting -keys, and no insignificant whitespace. `auctionId` matches +request order for results, the same order for slots, matching result order for bids, +lexically sorted targeting keys, and no insignificant whitespace. `auctionId` matches `^[A-Za-z0-9._:-]{1,128}$`; candidate ids use the exact 12-character base64url form from §3.4 and are unique; result slots are unique, follow the §2.2 bound, and contain no NUL or ASCII control; every winner has @@ -950,6 +957,23 @@ exactly one bid with the same slot/candidate and non-winners have none; no NUL or ASCII control. CPM is a finite nonnegative number and currency is exactly `USD`. +For initial HTML and `/_ts/page-bids`, `slots.length` equals +`auction.results.length` exactly. Entry `slots[i].slot` equals +`auction.results[i].slot`; slot ids are unique; and the entire projection is rejected +if any placement is missing, duplicated, extra, or out of order. `gamUnitPath` and +`divId` are nonempty, contain no NUL or ASCII control, and are each at most 256 UTF-8 +bytes. `formats` contains 1–64 exact two-number tuples and every width and height is +an integer in 1–4096. Placement `targeting` uses the same exact key/value grammar and +32-entry cap as bid targeting and cannot contain `hb_adid`. + +The direct `/auction` response does not expose this browser projection shape. Its +internal use of the canonical decision/bid serializer supplies `slots:[]` because +there is no server-rendered GAM placement to bind; the wire response remains the +exact OpenRTB response plus decision extension. Rust canonicalization therefore +accepts either full ordered placement coverage or the direct-only empty placement +vector, while the browser boot/page-bids parser accepts only full ordered coverage. +No browser consumer interprets an empty placement vector for a nonempty decision set. + Each bid's `targeting` member is a plain own-data object with at most 32 entries. A key matches `^[A-Za-z0-9_]{1,20}$`, is unique and case-sensitive, and cannot be `hb_adid`, which the runtime alone synthesizes from the reservation. A value is @@ -965,7 +989,8 @@ the existing no-bid/failed results in request order. For `/auction`, the corresp TS winner bids are likewise absent from `seatbid`; no unmatched decision or bid is emitted. The reduced projection is guaranteed to fit from the 256-result/id bounds. Initial HTML, page-bids, and direct response production use this same all-winners -rule, never a completion-order or first-fit subset. Boot rejects any independently +rule, never a completion-order or first-fit subset. The aggregate measurement includes +the complete ordered placement vector for browser projections. Boot rejects any independently malformed or oversized value as `abi_mismatch`; page-bids and direct response admission reject it transactionally as `invalid_response` with no partial slot, reservation, targeting, or bid state. @@ -1084,6 +1109,10 @@ stores the document-generation input at value; an SPA page-bids response replaces only the new session's internal projection through the transaction in §2.5. It never mutates `tsjs.boot`. A winner decision must join exactly one projected bid and a no-bid/failed decision must join none. +Every decision also joins its exact ordered `slots` placement. Static placement +targeting is applied first, bid targeting overrides a duplicate static key, and the +runtime alone synthesizes `hb_adid` from `rendererReservationId`; neither server +targeting object may provide that key. Targeting applies one identity rule from §2.2 and never truncates a value to fit GAM. If the chosen value cannot satisfy the 40-character targeting limit, the bid is rejected before targeting with an explicit local reason. @@ -1124,6 +1153,19 @@ GAM `hb_adid`, publish other targeting, record GPT intent, and invoke a request-capable GPT operation—in that order. Any failure before request invocation tombstones the reservation, compare-restores targeting, and settles the attempt. +The composition resolves each projected `divId` to the exact element first, then to +one unambiguous responsive/hydrated prefix match; container-shell aliases are not +treated as creative roots and ambiguity fails `slot_unresolved`. If GPT already owns +exactly one live slot for the resolved element, the slot service adopts that publisher +object and publishes with `refresh`. Otherwise the sole GPT adapter performs a +transactional `defineSlot`/`addService`/adoption and publishes with `display`. +Staleness destroys the unadopted candidate, and no path may leave a second physical +slot. Initial boot and every successfully committed page-bids replacement use this +same publisher. `pushState`, `replaceState`, and `popstate` share pathname-plus-query +identity; identical routes are suppressed, a current failed/rejected response rolls +back to the last committed path so the same route can retry, and an older response +cannot roll back or publish over a newer navigation generation. + For the Trusted Server Prebid adapter, the supported artifact is the content-addressed external bundle built from exactly lockfile-resolved Prebid.js 10.26.0. The external artifact contains no Trusted Server auction, admission, render, or refresh behavior. @@ -1987,9 +2029,10 @@ timer, listener, port, or iframe. It never exposes a compatibility API. The safe fallback boot uses the embedded release and `manifest:{version:1,releaseId,integrations:[]}`, independently retains the server -auction projection only when that projection passes its exact shape/256-slot bounds, +auction projection only when that projection passes its exact shape, full ordered +placement coverage, 256-slot bounds, field grammars, render limits, and 8 MiB aggregate cap from §§3.1–3.2, and otherwise substitutes exactly -`{version:1,auction:{version:1,auctionId:'fallback',results:[]},bids:[]}`. It +`{version:1,auction:{version:1,auctionId:'fallback',results:[]},slots:[],bids:[]}`. It retains a valid cache policy or omits it, and substitutes the creative/diagnostics disabled safe defaults from §§5.4/5.8 because no integration module commits. It never copies an accessor or unknown property. Fallback batch membership comes only from exact server slot ids in @@ -2012,23 +2055,23 @@ and late bundles; no valid call remains pending. There are no compatibility aliases: -| Baseline surface | Final surface | -| ---------------------------------------- | ------------------------------------------------------------------------------- | -| scattered `window.__tsjs_*` flags/config | `tsjs.boot.*` | -| `tsjs.adSlots`/`tsjs.bids` | initial `tsjs.boot.auctionProjection`; internal navigation projection after SPA | -| `tsjs.version === '0.1.0'` | `tsjs.version === '1.0.0'` plus `tsjs.releaseId` | -| `globalThis.tscreative` | no callable equivalent; automatic creative module | -| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | -| void/callback `requestAds` | `tsjs.requestAds(options): Promise` | -| placeholder `renderAdUnit` | `tsjs.requestAds({slots:[id]})` | -| placeholder `renderAllAdUnits` | `tsjs.requestAds()` | -| generic mutable `setConfig`/`getConfig` | immutable `tsjs.boot.*` plus typed integration config | -| `tsjs.renders`/`renderLog`/`renderSeq` | `tsjs.diagnostics.renderTrace` | -| `window` event `tsjs:adRendered` | `tsjs.diagnostics.renderTrace.subscribe(listener)` | -| `tsjs.gptDiagnostics` | `tsjs.diagnostics.gpt` | -| `window.__tsjs_prebid_bundle` | exact own `pbjs.__trustedServerArtifactV1` stamp | -| integration install/patch sentinels | kernel integration registry/`WeakSet` | -| GPT slot expandos | `SlotRecord` | +| Baseline surface | Final surface | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| scattered `window.__tsjs_*` flags/config | `tsjs.boot.*` | +| `tsjs.adSlots`/`tsjs.bids` | initial `tsjs.boot.auctionProjection` including exact ordered placements; internal navigation projection after SPA | +| `tsjs.version === '0.1.0'` | `tsjs.version === '1.0.0'` plus `tsjs.releaseId` | +| `globalThis.tscreative` | no callable equivalent; automatic creative module | +| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | +| void/callback `requestAds` | `tsjs.requestAds(options): Promise` | +| placeholder `renderAdUnit` | `tsjs.requestAds({slots:[id]})` | +| placeholder `renderAllAdUnits` | `tsjs.requestAds()` | +| generic mutable `setConfig`/`getConfig` | immutable `tsjs.boot.*` plus typed integration config | +| `tsjs.renders`/`renderLog`/`renderSeq` | `tsjs.diagnostics.renderTrace` | +| `window` event `tsjs:adRendered` | `tsjs.diagnostics.renderTrace.subscribe(listener)` | +| `tsjs.gptDiagnostics` | `tsjs.diagnostics.gpt` | +| `window.__tsjs_prebid_bundle` | exact own `pbjs.__trustedServerArtifactV1` stamp | +| integration install/patch sentinels | kernel integration registry/`WeakSet` | +| GPT slot expandos | `SlotRecord` | `window.tsjs.que` remains the pre-load command queue because it is the bootstrap transport, not a legacy behavior alias. @@ -2506,11 +2549,7 @@ subscription methods. The final schema is: ```ts type RenderTracePathV1 = 'auction' | 'ssat' | 'gam-refresh' type RenderTraceServedFromV1 = - | 'inline' - | 'gam' - | 'debug-adm' - | 'pbs-cache' - | 'prebid' + 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid' interface RenderTraceRecord { readonly slotId: string From ad9d90efefd9ec367f13a48a33722afbab975257 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:29:57 -0700 Subject: [PATCH 167/194] Test hard-cutover browser lifecycle races --- .github/workflows/integration-tests.yml | 24 +- .../browser/helpers/gpt-stub.ts | 515 +++++-- .../browser/playwright.config.ts | 27 +- .../tests/nextjs/gpt-diagnostics.spec.ts | 52 +- .../browser/tests/nextjs/navigation.spec.ts | 49 + .../tests/shared/aps-puc-lifecycle.spec.ts | 331 ++++ .../browser/tests/shared/aps-renderer.spec.ts | 1353 +++-------------- .../tests/shared/creative-sandbox.spec.ts | 86 +- .../browser/tests/shared/tsjs-runtime.spec.ts | 244 +++ .../lib/src/adapters/googletag.ts | 42 +- .../trusted-server-js/lib/src/core/index.ts | 45 +- .../lib/test/adapters/googletag.test.ts | 20 + .../lib/test/core/index.test.ts | 1 + scripts/integration-tests-browser.sh | 39 +- 14 files changed, 1504 insertions(+), 1324 deletions(-) create mode 100644 crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts create mode 100644 crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 48c82f451..82d09010b 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -326,14 +326,14 @@ jobs: if-no-files-found: error retention-days: 30 - browser-tests-aps-v1: - name: browser integration tests (APS v1 feature artifact) + 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 v1 browser test runtime + - name: Set up APS/TSJS browser test runtime id: shared-setup uses: ./.github/actions/setup-integration-test-env with: @@ -353,21 +353,25 @@ jobs: crates/trusted-server-integration-tests/browser/package-lock.json crates/trusted-server-js/lib/package-lock.json - - name: Run focused APS v1 Chromium test with explicit feature artifact + - name: Run focused APS/TSJS three-browser conformance matrix env: INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} TS_BROWSER_FRAMEWORKS: nextjs - TS_TEST_APS_V1: "1" + TS_BROWSER_PROJECTS: chromium,firefox,webkit run: >- ./scripts/integration-tests-browser.sh tests/shared/aps-renderer.spec.ts - --project=chromium - --grep="uses one port, reports ordered progress, and fails closed" - - - name: Upload APS v1 Playwright report + 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-v1 + name: playwright-report-aps-tsjs-conformance path: crates/trusted-server-integration-tests/browser/playwright-report/ retention-days: 7 diff --git a/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts b/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts index 6763b6b4b..40958eeec 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts @@ -2,115 +2,418 @@ import type { Page } from "@playwright/test"; /** Install a deterministic documented-event GPT stub before publisher scripts run. */ export async function installGptStub(page: Page): Promise { - await page.addInitScript(() => { - type StubSlot = { - getSlotElementId(): string; - getAdUnitPath(): string; - }; - type StubEvent = { slot: StubSlot } & Record; - type StubListener = (event: StubEvent) => void; + await page.addInitScript(() => { + type StubSlot = { + addService(service: object): StubSlot; + clearTargeting(key?: string): StubSlot; + getAdUnitPath(): string; + getSlotElementId(): string; + getTargeting(key: string): string[]; + setTargeting(key: string, value: string | string[]): StubSlot; + }; + type StubEvent = { slot: StubSlot } & Record; + type StubListener = (event: StubEvent) => void; - const listeners = new Map(); - const slots = new Map(); - const pubadsService = { - addEventListener(name: string, listener: StubListener) { - const current = listeners.get(name) ?? []; - current.push(listener); - listeners.set(name, current); - }, - refresh() {}, - }; - const commandQueue = { - push(callback: () => void) { - callback(); - return 1; - }, - }; - const googletag = { - cmd: commandQueue, - display() {}, - defineSlot() {}, - pubads: () => pubadsService, - }; - const references = { - commandPush: commandQueue.push, - display: googletag.display, - defineSlot: googletag.defineSlot, - refresh: pubadsService.refresh, - fetch: window.fetch, - xhrOpen: window.XMLHttpRequest.prototype.open, - pushState: window.history.pushState, - replaceState: window.history.replaceState, - }; + const listeners = new Map>(); + const slots = new Map(); + const physicalSlots = new Set(); + const displayCalls: StubSlot[] = []; + const refreshCalls: StubSlot[][] = []; + const universalCreativeLifecycle: string[] = []; + const emissions: Array>> = []; + const pageTargeting = new Map(); + let initialLoadDisabled = false; + let requestStartOnDisplay = false; + let nonemptyCompletionOnDisplay = false; + + const createSlot = (id: string, adUnitPath: string): StubSlot => { + const targeting = new Map(); + const slot: StubSlot = { + addService() { + return slot; + }, + clearTargeting(key?: string) { + if (key === undefined) targeting.clear(); + else targeting.delete(key); + return slot; + }, + getAdUnitPath: () => adUnitPath, + getSlotElementId: () => id, + getTargeting(key: string) { + return [...(targeting.get(key) ?? [])]; + }, + setTargeting(key: string, value: string | string[]) { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }, + }; + slots.set(id, slot); + return slot; + }; + + const slotForTarget = (target: unknown): StubSlot | undefined => { + if (typeof target === "string") return slots.get(target); + if (typeof target !== "object" || target === null) return undefined; + return [...physicalSlots].find((candidate) => candidate === target); + }; + + const emit = ( + name: string, + slot: StubSlot, + facts: Record = {}, + ): void => { + for (const listener of listeners.get(name) ?? []) { + listener({ slot, ...facts }); + } + }; - const browserWindow = window as unknown as { - googletag: typeof googletag; - __gptDiagnosticsStub: { - slot(id: string, adUnitPath?: string): StubSlot; - emit( - name: string, - slotId: string, - facts?: Record, - ): void; - listenerCounts(): Record; - captureReferences(): void; - referencesUnchanged(): boolean; + const pubadsService = { + addEventListener(name: string, listener: StubListener) { + const current = listeners.get(name) ?? new Set(); + current.add(listener); + listeners.set(name, current); + }, + removeEventListener(name: string, listener: StubListener) { + const current = listeners.get(name); + current?.delete(listener); + if (current?.size === 0) listeners.delete(name); + }, + disableInitialLoad() { + initialLoadDisabled = true; + }, + enableSingleRequest() {}, + getConfig() { + return { disableInitialLoad: initialLoadDisabled }; + }, + getSlots() { + return [...physicalSlots]; + }, + getTargeting(key: string) { + return [...(pageTargeting.get(key) ?? [])]; + }, + setTargeting(key: string, value: string | string[]) { + pageTargeting.set(key, Array.isArray(value) ? [...value] : [value]); + return pubadsService; + }, + refresh(requestedSlots?: StubSlot[]) { + const requested = requestedSlots ?? [...physicalSlots]; + refreshCalls.push([...requested]); + }, + }; + const commandQueue = { + push(callback: () => void) { + callback(); + return 1; + }, + }; + const googletag = { + apiReady: true, + pubadsReady: true, + cmd: commandQueue, + destroySlots(requested?: StubSlot[]) { + const candidates = requested ?? [...physicalSlots]; + for (const slot of candidates) physicalSlots.delete(slot); + return true; + }, + display(target: string | StubSlot) { + const slot = slotForTarget(target); + if (slot) { + displayCalls.push(slot); + if (requestStartOnDisplay) { + emissions.push({ + name: "slotRequested", + listeners: listeners.get("slotRequested")?.size ?? 0, + physical: physicalSlots.has(slot), + }); + emit("slotRequested", slot); + if (nonemptyCompletionOnDisplay) { + emissions.push({ + name: "slotRenderEnded", + listeners: listeners.get("slotRenderEnded")?.size ?? 0, + physical: physicalSlots.has(slot), + }); + emit("slotRenderEnded", slot, { + isEmpty: false, + responseIdentifier: "fictional-response-1", + }); + } + } + } + }, + defineSlot(adUnitPath: string, _sizes: unknown, elementId: string) { + const existing = slots.get(elementId); + const slot = existing ?? createSlot(elementId, adUnitPath); + physicalSlots.add(slot); + return slot; + }, + getConfig() { + return { disableInitialLoad: initialLoadDisabled }; + }, + pubads: () => pubadsService, + setConfig(config: { disableInitialLoad?: unknown }) { + if (typeof config?.disableInitialLoad === "boolean") { + initialLoadDisabled = config.disableInitialLoad; + } + }, + }; + const references = { + commandPush: commandQueue.push, + display: googletag.display, + defineSlot: googletag.defineSlot, + refresh: pubadsService.refresh, + fetch: window.fetch, + xhrOpen: window.XMLHttpRequest.prototype.open, + pushState: window.history.pushState, + replaceState: window.history.replaceState, + }; + + function fictionalUniversalCreative( + adId: string, + lifecycleIndex: number, + ): void { + type OwnerResponse = { + adId: string; + renderer: string; + }; + type FictionalPucWindow = Window & { + __fictionalPucOutcome?: string; + render?: ( + data: OwnerResponse, + helper: object, + creativeWindow: Window, + ) => Promise; + }; + + const pucWindow = window as FictionalPucWindow; + pucWindow.__fictionalPucOutcome = "pending"; + const recordOutcome = (outcome: string): void => { + pucWindow.__fictionalPucOutcome = outcome; + ( + window.parent as Window & { + __recordFictionalPucOutcome(index: number, value: string): void; + } + ).__recordFictionalPucOutcome(lifecycleIndex, outcome); + }; + + const prebidMessenger = (): Promise => + new Promise((resolve, reject) => { + const channel = new MessageChannel(); + const timeout = window.setTimeout(() => { + channel.port1.close(); + reject(new Error("fictional PUC response timeout")); + }, 5_000); + channel.port1.onmessage = (event) => { + window.clearTimeout(timeout); + channel.port1.close(); + try { + const response = JSON.parse(String(event.data)) as OwnerResponse; + resolve(response); + } catch (error) { + reject(error); + } + }; + channel.port1.start(); + window.parent.postMessage( + JSON.stringify({ + message: "Prebid Request", + adId, + adServerDomain: window.parent.location.host, + }), + "*", + [channel.port2], + ); + }); + + const runDynamicRenderer = async ( + response: OwnerResponse, + ): Promise => { + if (typeof response.renderer !== "string") { + throw new Error("fictional PUC response refused"); + } + window.eval(response.renderer); + if (typeof pucWindow.render !== "function") { + throw new Error("fictional PUC dynamic renderer unavailable"); + } + const helper = { + sendMessage( + message: string, + payload: Record, + callback: (event: MessageEvent) => void, + ) { + const channel = new MessageChannel(); + let active = true; + channel.port1.onmessage = (event) => { + if (active) callback(event); }; + channel.port1.start(); + window.parent.postMessage( + JSON.stringify({ + message, + adId: response.adId, + ...payload, + }), + "*", + [channel.port2], + ); + return () => { + active = false; + channel.port1.close(); + }; + }, }; - browserWindow.googletag = googletag; - browserWindow.__gptDiagnosticsStub = { - slot(id: string, adUnitPath = `/example/site/${id}`) { - let slot = slots.get(id); - if (!slot) { - slot = { - getSlotElementId: () => id, - getAdUnitPath: () => adUnitPath, - }; - slots.set(id, slot); - } - return slot; - }, - emit( - name: string, - slotId: string, - facts: Record = {}, - ) { - const slot = this.slot(slotId); - for (const listener of listeners.get(name) ?? []) { - listener({ slot, ...facts }); - } - }, - listenerCounts() { - return Object.fromEntries( - [...listeners.entries()].map(([name, registered]) => [ - name, - registered.length, - ]), - ); - }, - captureReferences() { - references.commandPush = commandQueue.push; - references.display = googletag.display; - references.defineSlot = googletag.defineSlot; - references.refresh = pubadsService.refresh; - references.fetch = window.fetch; - references.xhrOpen = window.XMLHttpRequest.prototype.open; - references.pushState = window.history.pushState; - references.replaceState = window.history.replaceState; - }, - referencesUnchanged() { - return ( - commandQueue.push === references.commandPush && - googletag.display === references.display && - googletag.defineSlot === references.defineSlot && - pubadsService.refresh === references.refresh && - window.fetch === references.fetch && - window.XMLHttpRequest.prototype.open === - references.xhrOpen && - window.history.pushState === references.pushState && - window.history.replaceState === references.replaceState - ); - }, + await pucWindow.render(response, helper, window); + }; + + void prebidMessenger() + .then(runDynamicRenderer) + .then( + () => { + recordOutcome("accepted"); + }, + (error: unknown) => { + const message = + error instanceof Error ? error.message : String(error); + recordOutcome(`failed:${message}`); + }, + ); + } + + const browserWindow = window as unknown as { + googletag: typeof googletag; + __recordFictionalPucOutcome(index: number, value: string): void; + __gptDiagnosticsStub: { + captureReferences(): void; + emitNonemptyCompletionOnDisplay(value?: boolean): void; + emitRequestStartOnDisplay(value?: boolean): void; + displayCount(): number; + emit( + name: string, + slotId: string, + facts?: Record, + ): void; + listenerCounts(): Record; + referencesUnchanged(): boolean; + refreshCount(): number; + renderUniversalCreative(slotId: string, adId: string): void; + slot(id: string, adUnitPath?: string): StubSlot; + targeting(slotId: string, key: string): readonly string[]; + universalCreativeSnapshot(): Readonly>; + }; + }; + browserWindow.googletag = googletag; + browserWindow.__recordFictionalPucOutcome = (index, value) => { + universalCreativeLifecycle[index] = value; + }; + browserWindow.__gptDiagnosticsStub = { + slot(id: string, adUnitPath = `/example/site/${id}`) { + return slots.get(id) ?? createSlot(id, adUnitPath); + }, + emit(name: string, slotId: string, facts: Record = {}) { + const slot = this.slot(slotId); + emissions.push({ + name, + listeners: listeners.get(name)?.size ?? 0, + physical: physicalSlots.has(slot), + }); + emit(name, slot, facts); + }, + listenerCounts() { + return Object.fromEntries( + [...listeners.entries()].map(([name, registered]) => [ + name, + registered.size, + ]), + ); + }, + displayCount() { + return displayCalls.length; + }, + emitRequestStartOnDisplay(value = true) { + requestStartOnDisplay = value; + }, + emitNonemptyCompletionOnDisplay(value = true) { + nonemptyCompletionOnDisplay = value; + }, + refreshCount() { + return refreshCalls.length; + }, + targeting(slotId: string, key: string) { + return slots.get(slotId)?.getTargeting(key) ?? []; + }, + renderUniversalCreative(slotId: string, adId: string) { + const root = document.getElementById(slotId); + if (!root) throw new Error(`missing fictional PUC slot: ${slotId}`); + const frame = document.createElement("iframe"); + const lifecycleIndex = universalCreativeLifecycle.length; + universalCreativeLifecycle.push("pending"); + frame.dataset.fictionalPuc = ""; + 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.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.goto(runtimeUrl("/aps-v1-protocol-test")); - - const makeDescriptor = (bidId: string) => { - const value = descriptor("iframe"); - value.bidId = bidId; - const envelope = JSON.parse( - Buffer.from(value.aaxResponse, "base64").toString("utf8"), - ) as { seatbid: Array<{ bid: Array<{ id: string }> }> }; - envelope.seatbid[0].bid[0].id = bidId; - value.aaxResponse = Buffer.from( - JSON.stringify(envelope), - "utf8", - ).toString("base64"); - return value; - }; - const start = async ( - slotId: string, - bidId: string, - rendererOverrides: Record = {}, - ) => { - const nonce = `n1_${slotId.padEnd(22, "x").slice(0, 22)}`; - await page.evaluate( - ({ slotId, nonce, renderer }) => { - ( - window as unknown as { - startApsV1(options: Record): void; - } - ).startApsV1({ slotId, nonce, renderer }); - }, - { - slotId, - nonce, - renderer: { - ...makeDescriptor(bidId), - ...rendererOverrides, - }, - }, - ); - return nonce; - }; - const messages = (slotId: string) => - page.evaluate( - (id) => - ( - window as unknown as { - apsV1Records: Record< - string, - { messages: Array> } - >; - } - ).apsV1Records[id]?.messages ?? [], - slotId, - ); - - await start("duplicate-success", "duplicate-success-bid"); - await expect - .poll(async () => - (await messages("duplicate-success")).map( - (message) => message.message, - ), - ) - .toEqual([ - "TS APS Document Accepted", - "TS APS Runner Loaded", - "TS APS Render Completed", - ]); - await expect(page.locator("#duplicate-success .existing")).toHaveCount( - 0, - ); - expect( - (await messages("duplicate-success")).filter((message) => - String(message.message).includes("Render "), - ), - ).toHaveLength(1); - - await start("reject-case", "reject-case-bid"); - await expect - .poll(async () => await messages("reject-case")) - .toContainEqual( - expect.objectContaining({ - message: "TS APS Render Failed", - reason: "runner_failed", - }), - ); - await expect(page.locator("#reject-case .existing")).toHaveCount(1); - - await start("silent-case", "silent-case-bid"); - await expect - .poll(async () => - (await messages("silent-case")).map( - (message) => message.message, - ), - ) - .toEqual(["TS APS Document Accepted", "TS APS Runner Loaded"]); - await page.waitForTimeout(150); - expect(await messages("silent-case")).toHaveLength(2); - - await start("nested-case", "nested-case-bid"); - await expect - .poll(async () => await messages("nested-case")) - .toContainEqual( - expect.objectContaining({ - message: "TS APS Render Completed", - }), - ); - - const requestsBeforeInvalid = runnerRequests; - await start("invalid-case", "invalid-case-bid", { - unexpected: true, - }); - await expect - .poll(async () => await messages("invalid-case")) - .toContainEqual( - expect.objectContaining({ - message: "TS APS Render Failed", - reason: "descriptor_invalid", - }), - ); - expect(runnerRequests).toBe(requestsBeforeInvalid); - - await page.unroute(runtimeUrl("/integrations/aps/runner.js")); - await page.route(runtimeUrl("/integrations/aps/runner.js"), (route) => - route.abort(), - ); - await start("load-failure-case", "load-failure-case-bid"); - await expect - .poll(async () => await messages("load-failure-case")) - .toContainEqual( - expect.objectContaining({ - message: "TS APS Render Failed", - reason: "runner_no_load", - }), - ); + }), + ); + await page.goto(runtimeUrl("/aps-v1-protocol-test")); + + const makeDescriptor = (bidId: string) => { + const value = descriptor(); + value.bidId = bidId; + const envelope = JSON.parse( + Buffer.from(value.aaxResponse, "base64").toString("utf8"), + ) as { seatbid: Array<{ bid: Array<{ id: string }> }> }; + envelope.seatbid[0].bid[0].id = bidId; + value.aaxResponse = Buffer.from( + JSON.stringify(envelope), + "utf8", + ).toString("base64"); + return value; + }; + const start = async ( + slotId: string, + bidId: string, + rendererOverrides: Record = {}, + ) => { + const nonce = `n1_${slotId.padEnd(22, "x").slice(0, 22)}`; + await page.evaluate( + ({ slotId, nonce, renderer }) => { + ( + window as unknown as { + startApsV1(options: Record): void; + } + ).startApsV1({ slotId, nonce, renderer }); + }, + { + slotId, + nonce, + renderer: { + ...makeDescriptor(bidId), + ...rendererOverrides, + }, + }, + ); + return nonce; + }; + const messages = (slotId: string) => + page.evaluate( + (id) => + ( + window as unknown as { + apsV1Records: Record< + string, + { messages: Array> } + >; + } + ).apsV1Records[id]?.messages ?? [], + slotId, + ); + + await start("duplicate-success", "duplicate-success-bid"); + await expect + .poll(async () => + (await messages("duplicate-success")).map((message) => message.message), + ) + .toEqual([ + "TS APS Document Accepted", + "TS APS Runner Loaded", + "TS APS Render Completed", + ]); + await expect(page.locator("#duplicate-success .existing")).toHaveCount(0); + expect( + (await messages("duplicate-success")).filter((message) => + String(message.message).includes("Render "), + ), + ).toHaveLength(1); + + await start("reject-case", "reject-case-bid"); + await expect + .poll(async () => await messages("reject-case")) + .toContainEqual( + expect.objectContaining({ + message: "TS APS Render Failed", + reason: "runner_failed", + }), + ); + await expect(page.locator("#reject-case .existing")).toHaveCount(1); + + await start("silent-case", "silent-case-bid"); + await expect + .poll(async () => + (await messages("silent-case")).map((message) => message.message), + ) + .toEqual(["TS APS Document Accepted", "TS APS Runner Loaded"]); + await page.waitForTimeout(150); + expect(await messages("silent-case")).toHaveLength(2); + + await start("nested-case", "nested-case-bid"); + await expect + .poll(async () => await messages("nested-case")) + .toContainEqual( + expect.objectContaining({ + message: "TS APS Render Completed", + }), + ); + + const requestsBeforeInvalid = runnerRequests; + await start("invalid-case", "invalid-case-bid", { + unexpected: true, }); + await expect + .poll(async () => await messages("invalid-case")) + .toContainEqual( + expect.objectContaining({ + message: "TS APS Render Failed", + reason: "descriptor_invalid", + }), + ); + expect(runnerRequests).toBe(requestsBeforeInvalid); + + await page.unroute(runtimeUrl("/integrations/aps/runner.js")); + await page.route(runtimeUrl("/integrations/aps/runner.js"), (route) => + route.abort(), + ); + await start("load-failure-case", "load-failure-case-bid"); + await expect + .poll(async () => await messages("load-failure-case")) + .toContainEqual( + expect.objectContaining({ + message: "TS APS Render Failed", + reason: "runner_no_load", + }), + ); + }); }); diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts index 517aeb403..7b3ef9f71 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts @@ -19,13 +19,46 @@ const CREATIVE_SANDBOX_TOKENS = [ // its own origin ahead of any creative markup, then the runtime, then the // creative. The anchor carries a root-relative signed click exactly as the // server-side rewriter emits it. -function creativeDocument(origin: string, bundleUrl: string): string { +function creativeDocument( + origin: string, + bundleUrl: string, + releaseId: string, +): string { const signedClick = "/first-party/click?tsurl=https%3A%2F%2Fadvertiser.example%2Flanding&foo=1&tstoken=browser-test-token"; + const boot = JSON.stringify({ + abi: 1, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [{ id: "creative", required: true }], + }, + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: "creative-sandbox", results: [] }, + slots: [], + bids: [], + }, + creative: { + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }, + diagnostics: { + version: 1, + renderTraceOverlay: false, + gpt: { active: false }, + }, + }); return ` - + @@ -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-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-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index af6b1fe44..bd6882b92 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -980,6 +980,10 @@ export function createBrowserGoogletagAdapter( 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(); const diagnosticsSlots = new WeakMap(); const initialLoadReleases = new Map void>(); @@ -1576,13 +1580,33 @@ export function createBrowserGoogletagAdapter( }; 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 && - inspected.value.display === expected.display && + sameAdapterMethod(inspected.value.display, expected.display) && inspected.value.pubads === expected.pubads ); }; @@ -2300,9 +2324,19 @@ export function createBrowserGoogletagAdapter( } return mediate(callable, this, arguments_); }; - const restore = replaceMethod(external, key, wrapper, stillCurrent); - if (!restore) throw new GoogletagAdapterError('external_artifact_incompatible'); - restorers[restorers.length] = restore; + 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_) => { diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index 93ef0a0b6..ea0935b88 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -38,10 +38,7 @@ export type BrowserRuntimeCompositionFactory = ( function bootstrapTarget(): BootstrapTarget | undefined { try { const current = (window as unknown as { tsjs?: unknown }).tsjs; - if ( - (typeof current === 'object' || typeof current === 'function') && - current !== null - ) { + if ((typeof current === 'object' || typeof current === 'function') && current !== null) { return current as BootstrapTarget; } const target: BootstrapTarget = {}; @@ -58,11 +55,7 @@ function snapshotConfigValue( state: { nodes: number }, depth = 0 ): unknown | typeof INVALID_CONFIG { - if ( - candidate === null || - typeof candidate === 'string' || - typeof candidate === 'boolean' - ) { + if (candidate === null || typeof candidate === 'string' || typeof candidate === 'boolean') { return candidate; } if (typeof candidate === 'number') { @@ -119,14 +112,10 @@ function snapshotConfigValue( } } -function consumeIntegrationConfig( - target: BootstrapTarget +function snapshotIntegrationConfig( + candidate: unknown ): Readonly> | undefined { try { - const descriptor = Object.getOwnPropertyDescriptor(target, '_integrationConfig'); - if (!descriptor) return Object.freeze({}); - if (!('value' in descriptor) || !descriptor.configurable) return undefined; - const candidate = descriptor.value; if ( typeof candidate !== 'object' || candidate === null || @@ -152,13 +141,33 @@ function consumeIntegrationConfig( if (value === INVALID_CONFIG) return undefined; configs[name] = value; } - if (!Reflect.deleteProperty(target, '_integrationConfig')) return undefined; 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'); @@ -173,9 +182,7 @@ function bootManifest(target: BootstrapTarget): unknown { } /** Claim the browser namespace and start the injected sole composition root. */ -export function startProductionRuntime( - createComposition: BrowserRuntimeCompositionFactory -): void { +export function startProductionRuntime(createComposition: BrowserRuntimeCompositionFactory): void { const target = bootstrapTarget(); if (!target) return; const configs = consumeIntegrationConfig(target); diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index 6e073ee16..9609fbc3e 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -2132,6 +2132,26 @@ describe('browser googletag adapter readiness', () => { expect(ready.pubads.refresh).toBe(nativeRefresh); }); + it('keeps an existing event subscription live while installing publisher-call wrappers', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const listener = vi.fn(); + let unsubscribe: (() => void) | undefined; + const subscription = adapter.run((gpt) => { + unsubscribe = gpt.subscribe('slotRequested', listener); + }); + await expect(subscription.result).resolves.toBeUndefined(); + const installed = [...(ready.listeners.get('slotRequested') ?? [])][0]; + expect(installed).toBeTypeOf('function'); + + const releasePublisherObserver = adapter.observePublisherCalls(Object.freeze({})); + installed?.({ slot: Object.freeze({ id: 'slot-a' }) }); + + expect(listener).toHaveBeenCalledOnce(); + unsubscribe?.(); + releasePublisherObserver(); + }); + it('installs the publisher observer when an accepted command-queue stub becomes ready', () => { const commands: Array<() => void> = []; const pending = { 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 b9cdf8324..bc54d39bc 100644 --- a/crates/trusted-server-js/lib/test/core/index.test.ts +++ b/crates/trusted-server-js/lib/test/core/index.test.ts @@ -96,5 +96,6 @@ describe('core production bootstrap', () => { const api = (window as unknown as { tsjs: TsjsApi }).tsjs; expect(api._internal).toMatchObject({ state: 'fallback', reason: 'abi_mismatch' }); expect(api).not.toHaveProperty('diagnostics'); + expect(api).not.toHaveProperty('_integrationConfig'); }); }); diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index 4940300a8..f9dcfbac9 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -23,17 +23,9 @@ NODE_VERSION="$(grep '^nodejs ' .tool-versions | awk '{print $2}')" FRAMEWORKS_VALUE="${TS_BROWSER_FRAMEWORKS:-nextjs wordpress}" FRAMEWORKS_VALUE="${FRAMEWORKS_VALUE//,/ }" read -r -a FRAMEWORKS <<< "$FRAMEWORKS_VALUE" -APS_V1_VALUE="${TS_TEST_APS_V1:-0}" -APS_V1_FEATURE_ARGS=() - -case "$APS_V1_VALUE" in - 0) ;; - 1) APS_V1_FEATURE_ARGS=(--features aps-runner-proxy-integration-test) ;; - *) - echo "TS_TEST_APS_V1 must be exactly 0 or 1" >&2 - exit 1 - ;; -esac +PROJECTS_VALUE="${TS_BROWSER_PROJECTS:-chromium}" +PROJECTS_VALUE="${PROJECTS_VALUE//,/ }" +read -r -a BROWSER_PROJECTS <<< "$PROJECTS_VALUE" if [ -z "$NODE_VERSION" ]; then echo "Failed to detect Node.js version from .tool-versions" >&2 @@ -45,6 +37,11 @@ if [ "${#FRAMEWORKS[@]}" -eq 0 ]; then exit 1 fi +if [ "${#BROWSER_PROJECTS[@]}" -eq 0 ]; then + echo "TS_BROWSER_PROJECTS must select at least one browser" >&2 + exit 1 +fi + for framework in "${FRAMEWORKS[@]}"; do case "$framework" in nextjs|wordpress) ;; @@ -55,6 +52,16 @@ for framework in "${FRAMEWORKS[@]}"; do esac done +for project in "${BROWSER_PROJECTS[@]}"; do + case "$project" in + chromium|firefox|webkit) ;; + *) + echo "Unsupported browser project: $project" >&2 + exit 1 + ;; + esac +done + # --- Build WASM binary --- echo "==> Building WASM binary (origin=http://127.0.0.1:$ORIGIN_PORT)..." TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" \ @@ -62,8 +69,7 @@ TRUSTED_SERVER__PUBLISHER__PROXY_SECRET="integration-test-proxy-secret" \ TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret-padded-32" \ TRUSTED_SERVER__EC__PARTNERS='[{"name":"Integration Test Partner","source_domain":"inttest.example.com","bidstream_enabled":true,"api_token":"integration-test-token-alpha-32-bytes-ok"},{"name":"Integration Test Partner 2","source_domain":"inttest2.example.com","bidstream_enabled":true,"api_token":"integration-test-token-bravo-32-bytes-ok"}]' \ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ - cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 \ - "${APS_V1_FEATURE_ARGS[@]}" + cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 echo "==> Generating Viceroy configs..." INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" ./scripts/generate-integration-viceroy-configs.sh @@ -87,7 +93,12 @@ done # --- Install Playwright --- echo "==> Installing Playwright dependencies..." npm --prefix "$BROWSER_DIR" ci -npm --prefix "$BROWSER_DIR" exec -- playwright install chromium +PLAYWRIGHT_INSTALL_ARGS=(install) +if [ "${CI:-}" = "true" ]; then + PLAYWRIGHT_INSTALL_ARGS+=(--with-deps) +fi +npm --prefix "$BROWSER_DIR" exec -- playwright \ + "${PLAYWRIGHT_INSTALL_ARGS[@]}" "${BROWSER_PROJECTS[@]}" # --- Build browser-side Trusted Server and external Prebid fixtures --- echo "==> Building TSJS browser fixtures..." From 9f3278d3c7705e6e05750a5ed2481d7f849949d2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:08:41 -0700 Subject: [PATCH 168/194] Fix hard-cutover browser boot races --- .../trusted-server-core/src/html_processor.rs | 5 +- crates/trusted-server-core/src/publisher.rs | 10 +- crates/trusted-server-core/src/tsjs.rs | 8 +- .../tests/nextjs/gpt-diagnostics.spec.ts | 126 +++++++++++++----- .../browser/tests/shared/aps-renderer.spec.ts | 62 +++++---- .../lib/src/composition/browser.ts | 7 +- .../lib/src/kernel/integration_registry.ts | 47 ++++++- .../lib/src/shared/origin.ts | 23 ++++ .../lib/test/composition/browser.test.ts | 4 +- .../test/kernel/integration_registry.test.ts | 53 +++++++- .../lib/test/kernel/runtime.test.ts | 5 +- .../lib/test/shared/origin.test.ts | 39 ++++++ 12 files changed, 304 insertions(+), 85 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/shared/origin.test.ts diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 754ac5cb8..57d8e34b9 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -19,8 +19,7 @@ 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":[]},"bids":[]}"#; +const EMPTY_AUCTION_PROJECTION_JSON: &str = r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"slots":[],"bids":[]}"#; /// Wraps [`HtmlRewriterAdapter`] with optional post-processing. /// @@ -1867,7 +1866,7 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - html.contains(r#""auctionId":"initial","results":[]},"bids":[]"#), + html.contains(r#""auctionId":"initial","results":[]},"slots":[],"bids":[]"#), "should inject the exact safe empty initial projection" ); assert!(!html.contains(".bids=")); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 2f6e4496c..60c77d70c 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -9059,7 +9059,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"); @@ -9099,12 +9099,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}" ); } diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 7794f4f8d..590f1c6d3 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -280,7 +280,7 @@ mod tests { let script = tsjs_boot_script_v1(TsjsBootScriptConfigV1 { module_ids: &["creative", "gpt", "gpt_diagnostics"], auction_projection_json: - r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"bids":[]}"#, + r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"slots":[],"bids":[]}"#, creative: CreativeBootConfigV1 { enabled: true, click_guard: true, @@ -293,7 +293,7 @@ mod tests { assert!(script.starts_with(""#, - "should emit the slim-Prebid URL as a JSON-encoded string assignment" - ); - } - - #[cfg(any())] - #[test] - fn head_inserts_escapes_script_terminator_in_slim_prebid_url() { - // A configured URL containing `` must not close the inline tag. - let config = GptConfig { - slim_prebid_url: Some("https://cdn.example.com/x".to_string()), - ..test_config() - }; - let integration = GptIntegration::new(config); - let doc_state = IntegrationDocumentState::default(); - let ctx = IntegrationHtmlContext { - request_host: "edge.example.com", - request_scheme: "https", - origin_host: "example.com", - document_state: &doc_state, - }; - - let inserts = integration.head_inserts(&ctx); - - // The injected `` must be neutralised: the only - // `` left is the tag's own legitimate closer. - assert!( - !inserts[2].contains(" terminator, got: {}", - inserts[2] - ); - assert_eq!( - inserts[2].matches("").count(), - 1, - "only the tag's own closing should remain, got: {}", - inserts[2] - ); - assert!( - inserts[2].contains("<\\/script>"), - "should emit the escaped terminator, got: {}", - inserts[2] - ); - } - - #[cfg(any())] - #[test] - fn head_inserts_omits_slim_prebid_url_when_not_configured() { - let integration = GptIntegration::new(test_config()); - let doc_state = IntegrationDocumentState::default(); - let ctx = IntegrationHtmlContext { - request_host: "edge.example.com", - request_scheme: "https", - origin_host: "example.com", - document_state: &doc_state, - }; - - let inserts = integration.head_inserts(&ctx); - - assert_eq!( - inserts.len(), - 2, - "should emit exactly two head inserts when slim_prebid_url is absent" - ); - assert!( - inserts - .iter() - .all(|s| !s.contains("__tsjs_slim_prebid_url")), - "should not emit slim-Prebid URL tag when not configured" - ); - } - - #[test] - fn proposed_generated_fallback_is_stamped_but_not_the_production_bootstrap() { - let release = trusted_server_js::release_id(); - let proposed = proposed_gpt_bootstrap_fallback_js(); - - assert_eq!( - proposed.matches(release).count(), - 1, - "generated proposal should carry the exact release once" - ); - assert!( - proposed.contains("runtime_unavailable"), - "generated proposal should contain the terminal fallback shell" - ); - assert!( - !GPT_BOOTSTRAP_JS.contains(release), - "Task 8 must not replace the production GPT bootstrap before Task 19" - ); - } } diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js deleted file mode 100644 index a57e5cf18..000000000 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ /dev/null @@ -1,495 +0,0 @@ -// Edge-injected GPT auction bootstrap. -// -// This is the minimal `window.tsjs.adInit` that runs on first page load -// before the TSJS bundle has had a chance to install its richer -// idempotent implementation. The bundle in -// crates/trusted-server-js/lib/src/integrations/gpt/index.ts overwrites `tsjs.adInit` -// once it loads. -// -// Contract with the bundle: -// - Both implementations must set `window.tsjs.servicesEnabled = true` -// after calling `enableSingleRequest()`/`enableServices()` so a -// subsequent call becomes a no-op. -// - `refresh()` is called only for the slots defined in this pass, -// never the global slot list. -// -// Only installed if `window.tsjs.adInit` isn't already defined. -(function () { - if (typeof window === "undefined") return; - var ts = (window.tsjs = window.tsjs || {}); - if (ts.adInit) return; - - // Track whether the publisher disabled GPT initial load. Read the effective - // googletag.getConfig() value when available, and wrap googletag.setConfig() - // and the legacy pubads().disableInitialLoad() method so changes are - // synchronized immediately and still detected when getConfig() is - // unavailable. With initial load disabled, display() only registers a slot - // and the ad request must come from a later refresh(); adInit() reads this to - // refresh its own freshly defined - // slots so they are not left blank. Pushed onto the command queue so it runs - // before the publisher's own GPT configuration. - function syncInitialLoadDisabled(gpt) { - if (typeof gpt.getConfig !== "function") return false; - var config = gpt.getConfig("disableInitialLoad"); - if (!config || typeof config.disableInitialLoad === "undefined") { - return false; - } - ts.gptInitialLoadDisabled = config.disableInitialLoad === true; - return true; - } - - (window.googletag = window.googletag || { cmd: [] }).cmd.push(function () { - var gpt = window.googletag; - syncInitialLoadDisabled(gpt); - if ( - typeof gpt.setConfig === "function" && - !gpt.__tsInitialLoadConfigHooked - ) { - var originalSetConfig = gpt.setConfig.bind(gpt); - gpt.setConfig = function (config) { - var result = originalSetConfig.apply(gpt, arguments); - if ( - !syncInitialLoadDisabled(gpt) && - config && - "disableInitialLoad" in config - ) { - ts.gptInitialLoadDisabled = config.disableInitialLoad === true; - } - return result; - }; - gpt.__tsInitialLoadConfigHooked = true; - } - - var pubads = gpt.pubads && gpt.pubads(); - if ( - !pubads || - typeof pubads.disableInitialLoad !== "function" || - pubads.__tsInitialLoadHooked - ) { - return; - } - var originalDisableInitialLoad = pubads.disableInitialLoad.bind(pubads); - pubads.disableInitialLoad = function () { - var result = originalDisableInitialLoad.apply(pubads, arguments); - if (!syncInitialLoadDisabled(gpt)) { - ts.gptInitialLoadDisabled = true; - } - return result; - }; - pubads.__tsInitialLoadHooked = true; - }); - - function findSlotByElementId(pubads, elementId) { - var slots = pubads.getSlots ? pubads.getSlots() : []; - return ( - slots.find(function (slot) { - return slot.getSlotElementId() === elementId; - }) || null - ); - } - - function normalizedGptFormats(formats) { - return formats.length === 2 && - formats.every(function (format) { - return typeof format === "number"; - }) - ? [formats] - : formats; - } - - function handoffFormatsMatch(handoff, formats) { - return ( - JSON.stringify(handoff.formats) === - JSON.stringify(normalizedGptFormats(formats)) - ); - } - - function matchingHandoff(pubads, adUnitPath, formats, elementId) { - var exact = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; - if (exact) return exact.publisherClaimed ? null : exact; - - var candidates = Object.values(ts.gptSlotHandoffs || {}).filter( - function (handoff, index, allHandoffs) { - return ( - allHandoffs.indexOf(handoff) === index && - !handoff.publisherClaimed && - !document.getElementById(handoff.slotElementId) && - elementId.startsWith(handoff.divIdPrefix) && - handoff.gamUnitPath === adUnitPath && - handoffFormatsMatch(handoff, formats) && - findSlotByElementId(pubads, handoff.slotElementId) - ); - }, - ); - return candidates.length === 1 ? candidates[0] : null; - } - - function displayTargetElementId(target) { - if (typeof target === "string") return target; - if (target && typeof target.getSlotElementId === "function") { - return target.getSlotElementId(); - } - return target && target.id ? target.id : null; - } - - function isElementVisible(element) { - var style = window.getComputedStyle(element); - return ( - style.display !== "none" && - style.visibility !== "hidden" && - style.visibility !== "collapse" - ); - } - - function slotElementHasLayout(element) { - if (!isElementVisible(element)) return false; - var elementRect = element.getBoundingClientRect(); - if (elementRect.width > 0 && elementRect.height > 0) return true; - - var container = document.getElementById(element.id + "-container"); - if (!container || !isElementVisible(container)) return false; - var containerRect = container.getBoundingClientRect(); - return containerRect.width > 0 && containerRect.height > 0; - } - - function findSlotElementByDivId(divId) { - if (!divId) return null; - var exact = document.getElementById(divId); - if (exact) return exact; - - var idElements = document.querySelectorAll("[id]"); - var prefixMatches = []; - for (var i = 0; i < idElements.length; i++) { - var candidate = idElements[i]; - if ( - candidate.id.startsWith(divId) && - !candidate.id.endsWith("-container") - ) { - prefixMatches.push(candidate); - } - } - // A unique prefix match may be a lazy slot that has not been sized yet. - // Geometry is only needed to disambiguate multiple responsive siblings. - if (prefixMatches.length === 1) return prefixMatches[0]; - - var visibleMatches = prefixMatches.filter(isElementVisible); - if (visibleMatches.length === 1) return visibleMatches[0]; - - var activeMatches = visibleMatches.filter(slotElementHasLayout); - if (activeMatches.length === 1) return activeMatches[0]; - - if ( - prefixMatches.length > 1 && - ts.log && - typeof ts.log.warn === "function" - ) { - ts.log.warn("GPT slot prefix did not resolve to one active element", { - divId: divId, - prefixMatchCount: prefixMatches.length, - activeMatchCount: activeMatches.length, - }); - } - return null; - } - - function runHandoffInternal(callback) { - var wasInternal = ts.gptSlotHandoffInternal; - ts.gptSlotHandoffInternal = true; - try { - return callback(); - } finally { - ts.gptSlotHandoffInternal = wasInternal; - } - } - - // TS cannot wait an arbitrary amount of time for a framework to define a - // slot: publishers that never define one would render blank. Instead, TS - // defines its fallback on the actual inner div and aliases only a later - // publisher defineSlot() for that exact div, or a hydration-renamed replacement - // after the original div is gone, to the same GPT slot. - function installSlotHandoff() { - window.googletag.cmd.push(function () { - var tag = window.googletag; - var pubads = tag.pubads && tag.pubads(); - if (!tag.defineSlot || !tag.display || !pubads) return; - - if (!tag.defineSlot.__tsSlotHandoffPatched) { - var originalDefineSlot = tag.defineSlot.bind(tag); - var patchedDefineSlot = function (adUnitPath, formats, elementId) { - if (!ts.gptSlotHandoffInternal && typeof elementId === "string") { - var handoff = matchingHandoff( - pubads, - adUnitPath, - formats, - elementId, - ); - if (handoff) { - var existingSlot = findSlotByElementId( - pubads, - handoff.slotElementId, - ); - if (existingSlot) { - ts.gptSlotHandoffs[elementId] = handoff; - handoff.publisherClaimed = true; - // The supported publisher lifecycle is defineSlot → addService → display. - // Intentionally wait for that display instead of applying a time heuristic. - handoff.suppressPublisherDisplay = true; - handoff.suppressPublisherRefresh = - ts.gptInitialLoadDisabled === true; - ts.prevGptSlots = (ts.prevGptSlots || []).filter( - function (ownedSlot) { - return ownedSlot !== existingSlot; - }, - ); - if ( - handoff.gamUnitPath !== adUnitPath || - !handoffFormatsMatch(handoff, formats) - ) { - ts.log && - ts.log.warn && - ts.log.warn( - "GPT slot handoff: publisher definition differs from TS configuration", - elementId, - ); - } - return existingSlot; - } - } - } - return elementId === undefined - ? originalDefineSlot(adUnitPath, formats) - : originalDefineSlot(adUnitPath, formats, elementId); - }; - patchedDefineSlot.__tsSlotHandoffPatched = true; - tag.defineSlot = patchedDefineSlot; - } - - if (!tag.display.__tsSlotHandoffPatched) { - var originalDisplay = tag.display.bind(tag); - var patchedDisplay = function (target) { - var elementId = displayTargetElementId(target); - var handoff = - elementId && ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; - if ( - !ts.gptSlotHandoffInternal && - handoff && - handoff.suppressPublisherDisplay - ) { - handoff.suppressPublisherDisplay = false; - return; - } - originalDisplay(target); - }; - patchedDisplay.__tsSlotHandoffPatched = true; - tag.display = patchedDisplay; - } - - if (!pubads.refresh.__tsSlotHandoffPatched) { - var originalRefresh = pubads.refresh.bind(pubads); - var callRefresh = function (slots, options) { - if (options === undefined) { - originalRefresh(slots); - } else { - originalRefresh(slots, options); - } - }; - var patchedRefresh = function (requestedSlots, options) { - if (ts.gptSlotHandoffInternal) { - callRefresh(requestedSlots, options); - return; - } - var slots = - requestedSlots || (pubads.getSlots ? pubads.getSlots() : null); - if (!slots) { - callRefresh(requestedSlots, options); - return; - } - var suppressed = false; - var remainingSlots = slots.filter(function (slot) { - var handoff = - ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()]; - if (!handoff || !handoff.suppressPublisherRefresh) return true; - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; - }); - if (!suppressed) { - callRefresh(requestedSlots, options); - } else if (remainingSlots.length > 0) { - callRefresh(remainingSlots, options); - } - }; - patchedRefresh.__tsSlotHandoffPatched = true; - pubads.refresh = patchedRefresh; - } - }); - } - - installSlotHandoff(); - - // Minimal fallback for tsjs.scheduleInitialAdInit, mirroring the bundle's - // hydration-safe scheduler in - // crates/trusted-server-js/lib/src/integrations/gpt/index.ts: the - // bids script hands the SSR bids payload to this scheduler, which applies - // it and runs adInit only while the page is still on navigation - // generation 0 (the SSR document), after window load plus a double - // requestAnimationFrame so the call lands outside React's hydration - // window. Keeps initial server-side ads working when the main TSJS bundle - // fails to load; the bundle overwrites this with the full implementation. - // - // Hidden documents: rAF is not serviced while the document is hidden, so a - // background-tab load holds the initial adInit until first view. Intended, - // and deliberately identical to the bundle scheduler — the impression is - // spent on a viewed tab, and the post-hydration guarantee holds whenever - // the request is actually issued. - ts.scheduleInitialAdInit = function (initialBids) { - if ((ts.navGeneration || 0) !== 0) return; - if (initialBids) ts.bids = initialBids; - var fire = function () { - if ((ts.navGeneration || 0) !== 0) return; - if (typeof ts.adInit === "function") ts.adInit(); - }; - var afterFrames = function () { - window.requestAnimationFrame(function () { - window.requestAnimationFrame(fire); - }); - }; - if (document.readyState === "complete") afterFrames(); - else window.addEventListener("load", afterFrames, { once: true }); - }; - - ts.adInit = function () { - var slots = ts.adSlots || []; - var bids = ts.bids || {}; - var divToSlotId = {}; - // Generation this invocation belongs to. The slot work below is queued on - // googletag.cmd, which drains only when GPT loads; recheck first inside - // the queued callback so a navigation committed in the gap cancels the - // stale mutation — mirrors the bundle's adInit. - var generation = ts.navGeneration || 0; - - googletag.cmd.push(function () { - if ((ts.navGeneration || 0) !== generation) return; - // Slots TS defined itself — tracked for SPA destroy. Publisher-owned - // slots are reused but never destroyed by TS on navigation. - var newSlots = []; - // Publisher-owned slots TS reused — refreshed to pick up server-side - // targeting. The publisher already display()ed these. - var slotsToRefresh = []; - // Element IDs of slots TS defined itself. GPT requires display() to - // register/render a freshly-defined slot; refresh() alone no-ops for a - // slot that was never displayed, so these are display()ed instead. - var slotsToDisplay = []; - slots.forEach(function (slot) { - // Resolve actual div ID: exact match first, then the one active prefix - // match. Responsive publishers may emit several mutually exclusive - // siblings for one stable prefix, so document order is not sufficient. - var el = findSlotElementByDivId(slot.div_id); - if (!el) return; - var actualDivId = el.id; - var b = bids[slot.id] || {}; - - var existingSlots = googletag.pubads().getSlots(); - var s = - existingSlots.find(function (gs) { - return gs.getSlotElementId() === actualDivId; - }) || null; - var tsOwned = false; - if (!s) { - // Define TS's fallback on the publisher's actual div. The scoped - // handoff wrapper returns this slot if the publisher defines it later. - s = runHandoffInternal(function () { - return googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - actualDivId, - ); - }); - if (!s) return; - s.addService(googletag.pubads()); - tsOwned = true; - ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; - ts.gptSlotHandoffs[actualDivId] = { - gamUnitPath: slot.gam_unit_path, - formats: slot.formats, - divIdPrefix: slot.div_id, - slotElementId: actualDivId, - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - } - - Object.entries(slot.targeting || {}).forEach(function (e) { - s.setTargeting(e[0], e[1]); - }); - [ - "hb_pb", - "hb_bidder", - "hb_adid", - "hb_cache_host", - "hb_cache_path", - ].forEach(function (k) { - if (b[k]) s.setTargeting(k, b[k]); - }); - // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts - s.setTargeting("ts_initial", "1"); - // Map the resolved inner div to the slot ID. This bootstrap fires no - // beacons and registers no slotRenderEnded listener; the map is consumed - // by the bundle's render bridge (index.ts) once it loads. - divToSlotId[actualDivId] = slot.id; - var slotElementId = s.getSlotElementId(); - if (slotElementId && slotElementId !== actualDivId) { - divToSlotId[slotElementId] = slot.id; - } - if (tsOwned) { - newSlots.push(s); - var displayId = s.getSlotElementId() || actualDivId; - slotsToDisplay.push(displayId); - } else { - slotsToRefresh.push(s); - } - }); - ts.prevGptSlots = newSlots; - ts.divToSlotId = divToSlotId; - if (!ts.servicesEnabled) { - googletag.pubads().enableSingleRequest(); - googletag.enableServices(); - ts.servicesEnabled = true; - } - // Register and render TS-defined slots. GPT requires display() for a - // freshly-defined slot; without it the slot no-ops and misses its - // impression. Runs after enableServices(); on SPA navigation services are - // already enabled, so this runs unconditionally for new slots. - slotsToDisplay.forEach(function (divId) { - runHandoffInternal(function () { - googletag.display(divId); - }); - }); - // Reused publisher-owned slots always need a refresh to pick up the - // server-side targeting. TS-defined slots are fetched by display() above - // unless the publisher disabled initial load, in which case display() only - // registers them and refresh() must request the ad — otherwise they render - // blank. Only add them in that case to avoid double-requesting. - syncInitialLoadDisabled(window.googletag); - var slotsNeedingRefresh = ts.gptInitialLoadDisabled - ? slotsToRefresh.concat(newSlots) - : slotsToRefresh; - if (slotsNeedingRefresh.length > 0) { - // One-shot bypass: this internal refresh delivers the just-applied - // server-side targeting to GAM. If slim-Prebid has already wrapped - // refresh(), it must pass this call straight through — not clear the - // targeting and run a duplicate client-side auction. Mirrors the - // bundle's adInit() in crates/trusted-server-js/lib/src/integrations/gpt/index.ts. - ts.adInitRefreshInProgress = true; - try { - runHandoffInternal(function () { - googletag.pubads().refresh(slotsNeedingRefresh); - }); - } finally { - ts.adInitRefreshInProgress = false; - } - } - }); - }; -})(); diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index a5d677836..fe0226d51 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -205,9 +205,8 @@ pub struct PrebidIntegrationConfig { pub enabled: bool, #[validate(url)] pub server_url: String, - /// Prebid Server account ID, injected into the client-side bundle via - /// `window.__tsjs_prebid.accountId` so publishers don't need to configure - /// it in JavaScript. + /// Prebid Server account ID delivered through the release-bound immutable + /// integration configuration so publishers do not configure it in JavaScript. #[serde(default)] pub account_id: Option, #[serde(default = "default_timeout_ms")] diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 60c77d70c..d1b90740e 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -21,20 +21,14 @@ //! into any [`Write`] (a `Vec` for buffered routes, a streaming writer for //! the streaming route). It is not a content-rewriting concern. -use std::borrow::Cow; use std::collections::{BTreeMap, HashSet}; use std::io::Write; use std::sync::{Arc, Mutex}; use std::time::Duration; -use brotli::Decompressor; -use brotli::enc::BrotliEncoderParams; -use brotli::enc::writer::CompressorWriter; use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use flate2::read::ZlibDecoder; -use flate2::write::{GzEncoder, ZlibEncoder}; use futures::StreamExt as _; use http::{HeaderValue, Method, Request, Response, StatusCode, Uri, header}; @@ -73,8 +67,8 @@ use crate::response_privacy::CDN_CACHE_HEADERS; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; use crate::streaming_processor::{ - BodyStreamDecoder, BodyStreamEncoder, Compression, GzipDecodeReader, PipelineConfig, - STREAM_CHUNK_SIZE, StreamProcessor, StreamingPipeline, + BodyStreamDecoder, BodyStreamEncoder, Compression, PipelineConfig, STREAM_CHUNK_SIZE, + StreamProcessor, StreamingPipeline, }; use crate::streaming_replacer::create_url_replacer; @@ -706,253 +700,6 @@ impl Drop for DispatchedAuctionGuard { } } -/// Mutable auction-hold state threaded through the streaming hold pipeline. -struct AuctionHoldState { - hold: Option, - dispatched: DispatchedAuctionGuard, - telemetry: AuctionTelemetryCarry, -} - -impl AuctionHoldState { - fn new(dispatched: DispatchedAuctionGuard, telemetry: AuctionTelemetryCarry) -> Self { - Self { - hold: Some(BodyCloseHoldBuffer::new()), - dispatched, - telemetry, - } - } -} - -/// Abandon the in-flight auction (if still pending) with the given telemetry -/// reason. No-op once the auction has been collected or already abandoned. -async fn abandon_hold_auction( - state: &mut AuctionHoldState, - services: &RuntimeServices, - reason: &'static str, -) { - if let Some(dispatched) = state.dispatched.take() { - emit_abandoned_auction( - services, - state.telemetry.observation.take(), - dispatched, - reason, - ) - .await; - // Abandonment with telemetry is a terminal result, so the drop warning - // is no longer warranted. (A drop *during* the emit above still fires - // it, since the guard stays armed until here.) - state.dispatched.disarm(); - } -} - -/// Output of a single close-body hold step, split at the auction-collection -/// barrier. -/// -/// `ready` is the prefix the caller must emit *before* collecting the auction, -/// so a small page whose `` lands in the first source chunk still -/// streams its document prefix immediately instead of stalling behind the -/// auction. `close_found` signals that `, - close_found: bool, -} - -/// Feed one decoded chunk through the close-body hold and processor. -/// -/// Returns the ready prefix for the caller to emit — written to a client stream -/// by [`body_close_hold_loop_stream`], yielded from the lazy body by -/// [`publisher_response_into_streaming_response`]. Both async hold paths share -/// this function so their behavior cannot drift apart. -/// -/// This step never awaits auction collection: it processes only the bytes the -/// hold buffer releases as ready and reports whether `( - processor: &mut P, - encoder: &mut BodyStreamEncoder, - chunk: &[u8], - state: &mut AuctionHoldState, - collect_refs: &AuctionCollectDeps<'_>, -) -> Result> { - let mut ready = Vec::new(); - let bytes: Cow<'_, [u8]> = match state.hold.as_mut() { - // Once the hold has been released the chunk streams straight through, - // borrowed rather than copied. - None => Cow::Borrowed(chunk), - Some(hold_buffer) => Cow::Owned(hold_buffer.push(chunk)), - }; - match process_and_encode_chunk(processor, encoder, &bytes, false, "Failed to process chunk") { - Ok(Some(encoded)) => ready.push(encoded), - Ok(None) => {} - Err(err) => { - abandon_hold_auction(state, collect_refs.services, "stream_process_error").await; - return Err(err); - } - } - let close_found = state - .hold - .as_ref() - .is_some_and(BodyCloseHoldBuffer::found_close); - Ok(HoldStepSegments { ready, close_found }) -} - -/// Collect the dispatched auction and process the held `` tail. -/// -/// Call only after [`hold_step_decoded_chunk`] (or -/// [`hold_finish_ready_segments`]) reports `close_found` and the ready prefix -/// has already been emitted: -/// collecting here — after the prefix streams — is what keeps the auction -/// riding alongside transfer instead of blocking it. Collection runs before the -/// tail is processed so `lol_html` sees live bids at the injection point. -async fn hold_collect_close_tail( - processor: &mut P, - encoder: &mut BodyStreamEncoder, - state: &mut AuctionHoldState, - collect_refs: &AuctionCollectDeps<'_>, -) -> Result, Report> { - let mut segments = Vec::new(); - let dispatched = state - .dispatched - .take() - .expect("should have dispatched auction to collect"); - collect_stream_auction(dispatched, state.telemetry.take(), collect_refs).await; - // Collection reached a terminal result; disarm only now so a drop while the - // collect await above was still pending is reported. - state.dispatched.disarm(); - - let held = state - .hold - .take() - .expect("should have close-body hold buffer") - .finish(); - if let Some(encoded) = process_and_encode_chunk( - processor, - encoder, - &held, - false, - "Failed to process held body close", - )? { - segments.push(encoded); - } - Ok(segments) -} - -/// Pull and decode the next chunk of the close-body hold pipeline, feeding it -/// through [`hold_step_decoded_chunk`]. -/// -/// Returns `Ok(None)` when the source is exhausted; the caller must then emit -/// [`hold_finish_ready_segments`] followed by [`hold_finish_tail_segments`]. On -/// read or decode failure the pending auction is -/// abandoned before the error is returned. Shared by the write-sink driver -/// ([`body_close_hold_loop_stream`]) and the lazy publisher body stream so -/// the two hold paths cannot drift apart. -async fn hold_step_next_chunk( - source: &mut BodyChunkSource, - decoder: &mut BodyStreamDecoder, - encoder: &mut BodyStreamEncoder, - processor: &mut P, - state: &mut AuctionHoldState, - collect_refs: &AuctionCollectDeps<'_>, -) -> Result, Report> { - let raw_chunk = match source.next_chunk().await { - Ok(Some(chunk)) => chunk, - Ok(None) => return Ok(None), - Err(err) => { - abandon_hold_auction(state, collect_refs.services, "stream_read_error").await; - return Err(err); - } - }; - let decoded = match decoder.decode_chunk(raw_chunk) { - Ok(decoded) => decoded, - Err(err) => { - abandon_hold_auction(state, collect_refs.services, "stream_decode_error").await; - return Err(err); - } - }; - if decoded.is_empty() { - return Ok(Some(HoldStepSegments { - ready: Vec::new(), - close_found: false, - })); - } - hold_step_decoded_chunk(processor, encoder, &decoded, state, collect_refs) - .await - .map(Some) -} - -/// Drain the decoder tail at end of the origin stream, returning the prefix the -/// caller must emit before [`hold_finish_tail_segments`]. -/// -/// A codec can hold document bytes back until its own finalization — the gzip -/// decoder releases the remainder of the final member at `finish()` — and that -/// remainder may be the whole document for a small page. Returning it ahead of -/// collection keeps the invariant the mid-stream path already has: only the -/// closing `` tail waits for the auction, never renderable content. -/// -/// On decoder failure the pending auction is abandoned before the error is -/// returned. -async fn hold_finish_ready_segments( - processor: &mut P, - decoder: &mut BodyStreamDecoder, - encoder: &mut BodyStreamEncoder, - state: &mut AuctionHoldState, - collect_refs: &AuctionCollectDeps<'_>, -) -> Result, Report> { - let decoded_tail = match decoder.finish() { - Ok(decoded_tail) => decoded_tail, - Err(err) => { - abandon_hold_auction(state, collect_refs.services, "stream_decode_error").await; - return Err(err); - } - }; - if decoded_tail.is_empty() { - return Ok(Vec::new()); - } - let step = - hold_step_decoded_chunk(processor, encoder, &decoded_tail, state, collect_refs).await?; - Ok(step.ready) -} - -/// Finalize the close-body hold pipeline after [`hold_finish_ready_segments`]. -/// -/// Collects the auction if the close-body tag never streamed, processes the held -/// tail plus the processor's final chunk, and emits the encoder trailer. Returns -/// the encoded segments for the caller to emit. -async fn hold_finish_tail_segments( - processor: &mut P, - encoder: &mut BodyStreamEncoder, - state: &mut AuctionHoldState, - collect_refs: &AuctionCollectDeps<'_>, -) -> Result, Report> { - let mut segments = Vec::new(); - - // If the hold is still armed the auction was never collected mid-stream: - // `` arrived only in the decoder tail, or the document had none at - // all. Collect now and flush the held remainder before finalizing. - if state.hold.is_some() { - segments.extend(hold_collect_close_tail(processor, encoder, state, collect_refs).await?); - } - - if let Some(encoded) = process_and_encode_chunk( - processor, - encoder, - &[], - true, - "Failed to finalize processor", - )? { - segments.push(encoded); - } - let trailer = encoder.finish()?; - if !trailer.is_empty() { - segments.push(bytes::Bytes::from(trailer)); - } - Ok(segments) -} - /// Create a unified HTML stream processor. /// /// Builds the config via [`HtmlProcessorConfig::from_settings`] and then @@ -1233,6 +980,12 @@ pub async fn buffer_publisher_response_async( /// Returns an error if processor construction fails before the streaming body /// is created; a dispatched auction is abandoned with `processor_init_error` /// telemetry first, matching the buffered finalizer. +/// +/// # Panics +/// +/// Panics if an internal streaming auction guard is missing after this helper +/// has committed to collecting it. That state indicates a violated internal +/// ownership invariant. pub async fn publisher_response_into_streaming_response( publisher_response: PublisherResponse, method: &Method, @@ -1989,22 +1742,6 @@ struct AuctionTelemetryCarry { auction_request: Option, } -impl AuctionTelemetryCarry { - fn take(&mut self) -> Self { - Self { - observation: self.observation.take(), - auction_request: self.auction_request.take(), - } - } -} - -/// Bundles the auction-collection state passed through the streaming helpers. -struct AuctionCollectCtx<'a> { - dispatched: DispatchedAuction, - telemetry: AuctionTelemetryCarry, - deps: AuctionCollectDeps<'a>, -} - /// Borrowed dependencies of the auction collect step. /// /// Split from the per-auction state above because `dispatched` and `telemetry` @@ -2021,334 +1758,6 @@ struct AuctionCollectDeps<'a> { request_origin: String, } -/// Run the close-body hold loop for HTML bodies, collecting the auction before -/// the raw `( - body: EdgeBody, - output: &mut W, - processor: &mut P, - compression: Compression, - ctx: AuctionCollectCtx<'_>, -) -> Result<(), Report> { - if body.is_stream() { - let max_body_bytes = ctx.deps.settings.publisher.max_buffered_body_bytes; - return body_close_hold_loop_stream( - body, - output, - processor, - compression, - ctx, - max_body_bytes, - ) - .await; - } - - // Bound the gzip decode budget to the same ceiling the buffered writer - // enforces, matching the streaming arm above and the no-hold buffered path. - let max_body_bytes = ctx.deps.settings.publisher.max_buffered_body_bytes; - let body = body_as_reader(body)?; - match compression { - Compression::None => body_close_hold_loop(body, output, processor, ctx).await, - Compression::Gzip => { - // `GzipDecodeReader` decodes concatenated gzip members (RFC 1952) - // and bounds decoded output, unlike `flate2::read::GzDecoder`, which - // silently drops every member after the first — dropping trailing - // markup (potentially including ``) on buffered adapters. - let decoder = GzipDecodeReader::new(body, max_body_bytes); - let mut encoder = GzEncoder::new(&mut *output, flate2::Compression::default()); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; - encoder.finish().change_context(TrustedServerError::Proxy { - message: "Failed to finalize gzip encoder".to_string(), - })?; - Ok(()) - } - Compression::Deflate => { - let decoder = ZlibDecoder::new(body); - let mut encoder = ZlibEncoder::new(&mut *output, flate2::Compression::default()); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; - encoder.finish().change_context(TrustedServerError::Proxy { - message: "Failed to finalize deflate encoder".to_string(), - })?; - Ok(()) - } - Compression::Brotli => { - let decoder = Decompressor::new(body, STREAM_CHUNK_SIZE); - let params = BrotliEncoderParams { - quality: 4, - lgwin: 22, - ..Default::default() - }; - let mut encoder = - CompressorWriter::with_params(&mut *output, STREAM_CHUNK_SIZE, ¶ms); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; - let _ = encoder.into_inner(); - Ok(()) - } - } -} - -/// Async-pull variant of [`body_close_hold_loop`] for live origin streams. -/// -/// Shares [`hold_step_next_chunk`] and the finish stages with the -/// lazy streaming body built by [`publisher_response_into_streaming_response`], -/// so the two async hold paths cannot drift apart. -/// -/// No production caller reaches this today: it is only entered through -/// [`buffer_publisher_response_async`], and the buffered adapters (Axum, -/// Cloudflare, Spin) never produce `Body::Stream` because the publisher fetch -/// is gated on `supports_streaming_responses()`. It is groundwork for those -/// adapters' streaming cutover; Fastly uses the lazy stream instead. -async fn body_close_hold_loop_stream( - body: EdgeBody, - writer: &mut W, - processor: &mut P, - compression: Compression, - ctx: AuctionCollectCtx<'_>, - max_body_bytes: usize, -) -> Result<(), Report> { - let AuctionCollectCtx { - dispatched, - telemetry, - deps: collect_refs, - } = ctx; - let mut decoder = BodyStreamDecoder::new(compression, max_body_bytes); - let mut encoder = BodyStreamEncoder::new(compression); - let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_body_bytes); - let mut state = AuctionHoldState::new(DispatchedAuctionGuard::new(dispatched), telemetry); - - while let Some(step) = hold_step_next_chunk( - &mut source, - &mut decoder, - &mut encoder, - processor, - &mut state, - &collect_refs, - ) - .await? - { - // Write the ready prefix before collecting the auction, matching the - // lazy Fastly stream: only the held `` tail waits on collection. - for encoded in step.ready { - write_encoded_segment(writer, &encoded)?; - } - if step.close_found { - for encoded in - hold_collect_close_tail(processor, &mut encoder, &mut state, &collect_refs).await? - { - write_encoded_segment(writer, &encoded)?; - } - } - } - - // Write the decoder-finalized prefix before collection, matching the lazy - // Fastly stream: only the held `` tail waits on the auction. - for encoded in hold_finish_ready_segments( - processor, - &mut decoder, - &mut encoder, - &mut state, - &collect_refs, - ) - .await? - { - write_encoded_segment(writer, &encoded)?; - } - for encoded in - hold_finish_tail_segments(processor, &mut encoder, &mut state, &collect_refs).await? - { - write_encoded_segment(writer, &encoded)?; - } - writer.flush().change_context(TrustedServerError::Proxy { - message: "Failed to flush output".to_string(), - })?; - Ok(()) -} - -const BODY_CLOSE_PREFIX: &[u8] = b", - found_close: bool, -} - -impl BodyCloseHoldBuffer { - fn new() -> Self { - Self { - buffered: Vec::new(), - found_close: false, - } - } - - fn push(&mut self, chunk: &[u8]) -> Vec { - self.buffered.extend_from_slice(chunk); - - if self.found_close { - return Vec::new(); - } - - if let Some(pos) = find_ascii_case_insensitive(&self.buffered, BODY_CLOSE_PREFIX) { - self.found_close = true; - return self.buffered.drain(..pos).collect(); - } - - let keep_len = BODY_CLOSE_PREFIX.len().saturating_sub(1); - if self.buffered.len() <= keep_len { - return Vec::new(); - } - - let split_at = self.buffered.len() - keep_len; - self.buffered.drain(..split_at).collect() - } - - fn found_close(&self) -> bool { - self.found_close - } - - fn finish(self) -> Vec { - self.buffered - } -} - -fn find_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> Option { - haystack.windows(needle.len()).position(|window| { - window - .iter() - .zip(needle) - .all(|(left, right)| left.eq_ignore_ascii_case(right)) - }) -} - -/// Core close-body hold loop. -/// -/// Streams processed output until the first case-insensitive `( - mut reader: R, - writer: &mut W, - processor: &mut P, - ctx: AuctionCollectCtx<'_>, -) -> Result<(), Report> { - let AuctionCollectCtx { - dispatched, - mut telemetry, - deps, - } = ctx; - let mut buffer = vec![0u8; STREAM_CHUNK_SIZE]; - let mut hold = Some(BodyCloseHoldBuffer::new()); - let mut dispatched = Some(dispatched); - - loop { - match reader.read(&mut buffer) { - Ok(0) => { - if let Some(hold) = hold.take() { - let dispatched = dispatched - .take() - .expect("should have dispatched auction to collect"); - collect_stream_auction(dispatched, telemetry.take(), &deps).await; - - let held = hold.finish(); - write_processed_chunk( - writer, - processor, - &held, - false, - "Failed to process held body close", - "Failed to write held body close", - )?; - } - // Signal EOF to lol_html (fires end() which flushes remaining state). - let final_out = processor.process_chunk(&[], true).change_context( - TrustedServerError::Proxy { - message: "Failed to finalize processor".to_string(), - }, - )?; - if !final_out.is_empty() { - writer - .write_all(&final_out) - .change_context(TrustedServerError::Proxy { - message: "Failed to write finalized output".to_string(), - })?; - } - break; - } - Ok(n) => { - if let Some(hold_buffer) = hold.as_mut() { - let ready = hold_buffer.push(&buffer[..n]); - if let Err(err) = write_processed_chunk( - writer, - processor, - &ready, - false, - "Failed to process chunk", - "Failed to write chunk", - ) { - if let Some(dispatched) = dispatched.take() { - emit_abandoned_auction( - deps.services, - telemetry.observation.take(), - dispatched, - "stream_process_error", - ) - .await; - } - return Err(err); - } - - if hold_buffer.found_close() { - let dispatched = dispatched - .take() - .expect("should have dispatched auction to collect"); - collect_stream_auction(dispatched, telemetry.take(), &deps).await; - - let held = hold - .take() - .expect("should have close-body hold buffer") - .finish(); - write_processed_chunk( - writer, - processor, - &held, - false, - "Failed to process held body close", - "Failed to write held body close", - )?; - } - } else { - write_processed_chunk( - writer, - processor, - &buffer[..n], - false, - "Failed to process chunk", - "Failed to write chunk", - )?; - } - } - Err(e) => { - if let Some(dispatched) = dispatched.take() { - emit_abandoned_auction( - deps.services, - telemetry.observation.take(), - dispatched, - "stream_read_error", - ) - .await; - } - return Err(Report::new(TrustedServerError::Proxy { - message: format!("Failed to read origin body: {e}"), - })); - } - } - } - - writer.flush().change_context(TrustedServerError::Proxy { - message: "Failed to flush output".to_string(), - })?; - Ok(()) -} - async fn emit_abandoned_auction( services: &RuntimeServices, observation: Option, @@ -2418,7 +1827,7 @@ async fn collect_non_html_auction( } } -// Private orchestration helper called only from `body_close_hold_loop`. +// Private orchestration helper used by the streaming response paths. // `dispatched` and `telemetry` are moved per collect, so they stay by value // while the rest of the context is borrowed. async fn collect_stream_auction( @@ -2435,14 +1844,14 @@ async fn collect_stream_auction( settings, request_origin, } = deps; - log::info!("body_close_hold_loop: collecting dispatched auction before held body tail"); + log::info!("streaming response: collecting dispatched auction"); let placeholder = mediator_placeholder_request(); let collect_ctx = make_collect_context(settings, services, &placeholder); let result = orchestrator .collect_dispatched_auction(dispatched, services, &collect_ctx) .await; log::info!( - "body_close_hold_loop: collect complete - {} winning bid(s)", + "streaming response: collect complete - {} winning bid(s)", result.winning_bids.len() ); let delivered_winner_slots = write_projection_to_state( @@ -2474,35 +1883,6 @@ async fn collect_stream_auction( } } -fn write_processed_chunk( - writer: &mut W, - processor: &mut P, - chunk: &[u8], - is_last: bool, - process_error: &str, - write_error: &str, -) -> Result<(), Report> { - if chunk.is_empty() && !is_last { - return Ok(()); - } - - let out = - processor - .process_chunk(chunk, is_last) - .change_context(TrustedServerError::Proxy { - message: process_error.to_string(), - })?; - if !out.is_empty() { - writer - .write_all(&out) - .change_context(TrustedServerError::Proxy { - message: write_error.to_string(), - })?; - } - - Ok(()) -} - /// Auction dispatch context passed to [`handle_publisher_request`]. pub struct AuctionDispatch<'a> { /// Orchestrator that dispatches and collects SSP bid requests. @@ -3215,38 +2595,6 @@ pub(crate) fn build_auction_request( } } -/// Escape a JSON string so it is safe to embed inside a JS double-quoted string literal -/// inside an HTML `` injection breaking out of the script context -/// - U+2028, U+2029 — line/paragraph separators that are valid JSON but terminate -/// a JS string literal in some parsers -/// -/// All substitutions use `\uXXXX` form, which is valid inside both JSON strings -/// and JS string literals. The result is always safe to write as `JSON.parse("…")`. -fn html_escape_for_script(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for ch in s.chars() { - match ch { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '<' => out.push_str("\\u003C"), - '>' => out.push_str("\\u003E"), - '&' => out.push_str("\\u0026"), - '\u{2028}' => out.push_str("\\u2028"), - '\u{2029}' => out.push_str("\\u2029"), - _ => out.push(ch), - } - } - out -} - -#[allow( - dead_code, - reason = "pure coordinated-cutover projection is wired to entry points in Task 19" -)] pub(crate) mod coordinated_cutover_v1 { use super::*; @@ -3465,335 +2813,6 @@ pub(crate) mod coordinated_cutover_v1 { } } -/// Build a price-bucketed bid map from winning bids. -/// -/// Returns a JSON object map of slot ID → bid metadata including the bucketed -/// CPM (`hb_pb`), bidder (`hb_bidder`), and optional ad ID, nurl, and burl. -pub(crate) fn build_bid_map( - winning_bids: &std::collections::HashMap, - granularity: crate::price_bucket::PriceGranularity, - settings: &Settings, - request_origin: &str, - include_debug_bid: bool, -) -> serde_json::Map { - build_bid_map_with_auction_id( - winning_bids, - granularity, - settings, - request_origin, - include_debug_bid, - None, - ) -} - -fn build_bid_map_with_auction_id( - winning_bids: &std::collections::HashMap, - granularity: crate::price_bucket::PriceGranularity, - settings: &Settings, - request_origin: &str, - include_debug_bid: bool, - auction_id: Option<&str>, -) -> serde_json::Map { - // Inline creatives render in a foreign origin (PUC's srcdoc under GAM), so - // their proxy/click URLs must be absolute against the origin the visitor is - // actually on — scheme, host, and port. Fall back to the configured publisher - // domain only when the request origin is unknown (e.g. an empty host on a - // non-navigation path), where no inline render is expected anyway. - let base_origin = if request_origin.is_empty() { - format!("https://{}", settings.publisher.domain) - } else { - request_origin.to_owned() - }; - winning_bids - .iter() - .filter_map(|(slot_id, bid)| { - bid.price.map(|cpm| { - let bucket = price_bucket(cpm, granularity); - let mut obj = serde_json::Map::new(); - obj.insert("hb_pb".to_string(), serde_json::Value::String(bucket)); - obj.insert( - "hb_bidder".to_string(), - serde_json::Value::String(bid.bidder.clone()), - ); - // Winning creative dimensions — the bridge sizes the inline - // render from these, falling back to the first configured slot - // format only when absent, which mis-sizes a multi-size slot. - // Omit a zero dimension (missing OpenRTB w/h parse to 0) so the - // bridge falls back rather than sizing the frame to 0. - if bid.width > 0 { - obj.insert("w".to_string(), serde_json::Value::from(bid.width)); - } - if bid.height > 0 { - obj.insert("h".to_string(), serde_json::Value::from(bid.height)); - } - // PBS Cache remains highest priority. Typed renderer bids use - // their selected upstream bid ID as the Universal Creative key. - // - // `bid.bid_id` (the OpenRTB bid's own `id`) is the last resort: it is - // always present per spec but only unique per bid instance, not a - // creative identifier. It still satisfies what hb_adid needs here — - // a stable value GAM's Universal Creative echoes back verbatim so - // the render bridge can find this exact winning bid — for bidders - // that return neither a cache UUID nor `adid`. Without it those - // bids carry no hb_adid at all, so no targeting key reaches GAM and - // the render handshake can never start. - let renderer_bid_id = bid.renderer.as_ref().and(bid.bid_id.as_deref()); - let hb_adid = bid - .cache_id - .as_deref() - .or(renderer_bid_id) - .or(bid.ad_id.as_deref()) - .or(bid.bid_id.as_deref()); - if let Some(auction_id) = auction_id.filter(|id| !id.is_empty()) { - obj.insert( - "hb_auction_id".to_string(), - serde_json::Value::String(auction_id.to_string()), - ); - } - if let Some(bid_id) = bid.bid_id.as_ref() { - obj.insert( - "hb_bid_id".to_string(), - serde_json::Value::String(bid_id.clone()), - ); - } - if let Some(creative_id) = bid.creative_id.as_ref() { - obj.insert( - "hb_crid".to_string(), - serde_json::Value::String(creative_id.clone()), - ); - } - if let Some(id) = hb_adid { - obj.insert( - "hb_adid".to_string(), - serde_json::Value::String(id.to_string()), - ); - } - - // Win/billing notification URLs, fired verbatim by the bridge. - // Per OpenRTB these are the canonical carriers of - // `${AUCTION_PRICE}`, so expand it from the same winning CPM used - // for the creative below — an unexpanded macro would report an - // unresolved clearing price to the SSP, and some reject such - // notifications outright. - if let Some(ref nurl) = bid.nurl { - let nurl = crate::creative::expand_auction_price_macro(nurl, cpm); - obj.insert("nurl".to_string(), serde_json::Value::String(nurl)); - } - if let Some(ref burl) = bid.burl { - let burl = crate::creative::expand_auction_price_macro(burl, cpm); - obj.insert("burl".to_string(), serde_json::Value::String(burl)); - } - if let Some(ref renderer) = bid.renderer { - obj.insert( - "renderer".to_string(), - serde_json::to_value(renderer).expect("should serialize typed renderer"), - ); - } - // Always include the winning creative so the pbRender bridge can - // render it locally when GAM serves the Prebid Universal Creative - // — no PBS Cache round trip. - // - // Optionally sanitize dangerous markup, then optionally rewrite - // URLs to first-party proxies — the same opt-in creative-processing - // policy as the `/auction` path (see `auction::formats`), except - // for the inline render context. This `adm` is rendered by the - // Prebid Universal Creative inside GAM's iframe (`f.srcdoc = d.ad`), - // a foreign origin where root-relative `/first-party/…` URLs resolve - // against GAM and 404. The inline rewriter therefore emits - // absolute first-party URLs and omits the tsjs bundle injection. - // - // `None` means the bid carried no `creative` field at all; every - // `Some(raw)` — including an explicit empty string, which PBS can - // return — is a supplied creative and goes through processing, so - // an empty `adm` cannot masquerade as "absent" and re-enable the - // raw cache fallback below. Processing may reject the creative - // outright (empty output): sanitization can strip everything, - // parsing can fail, or the size cap can trip. - let processed_adm = bid.creative.as_ref().map(|raw_creative| { - // Resolve ${AUCTION_PRICE} from the exact winning CPM BEFORE - // sanitizing, rewriting, and signing — URL rewriting would - // otherwise encode the literal macro into the signed proxy/click - // URL, and signing would lock that wrong value. - let priced = crate::creative::expand_auction_price_macro(raw_creative, cpm); - crate::creative::process_inline_auction_creative( - settings, - &base_origin, - &priced, - ) - }); - // Cache endpoint coordinates — only present for PBS bids with - // Prebid Cache enabled, and only when the bid supplied no creative - // of its own. The Prebid Universal Creative constructs: - // https://?uuid= - // and renders the cached bid's ORIGINAL adm, bypassing every - // server-side processing policy. Emitting them alongside a - // supplied creative would therefore hand the client an - // unprocessed copy of markup we just sanitized, rewrote, or - // rejected — so they ship only for genuinely absent creatives, - // where they are the sole render source. - match processed_adm { - Some(adm) if !adm.is_empty() => { - obj.insert( - "hb_adm_hash".to_string(), - serde_json::Value::String(crate::auction::types::adm_trace_hash(&adm)), - ); - obj.insert("adm".to_string(), serde_json::Value::String(adm)); - } - Some(_) => { - log::warn!( - "build_bid_map: creative for slot {} bidder {} rejected by processing; suppressing PBS Cache fallback", - slot_id, - bid.bidder - ); - } - None => { - if let Some(ref host) = bid.cache_host { - obj.insert( - "hb_cache_host".to_string(), - serde_json::Value::String(host.clone()), - ); - } - if let Some(ref path) = bid.cache_path { - obj.insert( - "hb_cache_path".to_string(), - serde_json::Value::String(path.clone()), - ); - } - } - } - // Verbose per-bid debug blob only under the testing flag; also - // doubles as the client-side gate for the direct GAM-replace path. - // Deliberately mirrors the bidder-supplied `creative`/`nurl`/`burl` - // verbatim, macros unexpanded: this blob is diagnostic — nothing - // renders or fires from it — and showing what the bidder actually - // sent is the point. - if include_debug_bid { - obj.insert( - "debug_bid".to_string(), - serde_json::json!({ - "slot_id": bid.slot_id, - "price": bid.price, - "currency": bid.currency, - "creative": bid.creative, - "adomain": bid.adomain, - "bidder": bid.bidder, - "width": bid.width, - "height": bid.height, - "nurl": bid.nurl, - "burl": bid.burl, - "bid_id": bid.bid_id, - "ad_id": bid.ad_id, - "creative_id": bid.creative_id, - "cache_id": bid.cache_id, - "cache_host": bid.cache_host, - "cache_path": bid.cache_path, - "metadata": bid.metadata, - }), - ); - } - (slot_id.clone(), serde_json::Value::Object(obj)) - }) - }) - .collect() -} - -/// Build the `tsjs.bids` `` sequences inside the string. -pub(crate) fn build_bids_script(bid_map: &serde_json::Map) -> String { - let json = serde_json::to_string(bid_map) - .expect("serde_json::to_string of Map should be infallible"); - let escaped = html_escape_for_script(&json); - // adInit() defines GPT slots on the publisher's `-container` wrappers, which - // mutates those ad-slot subtrees. Calling it synchronously here (this script - // runs at body-parse time) lands those mutations inside React's hydration - // window and trips a #418 hydration mismatch. The deferral — gate on window - // `load`, then a double `requestAnimationFrame`, pinned to navigation - // generation 0 so a faster SPA navigation cancels it — lives in the GPT - // bundle module as `tsjs.scheduleInitialAdInit` - // (crates/trusted-server-js/lib/src/integrations/gpt/index.ts), where the - // lifecycle is executable under Vitest (schedule_initial_ad_init.test.ts) - // and the navigation-generation guard is shared with the SPA auction hook; - // gpt_bootstrap.js installs a minimal head-injected fallback so a failed - // bundle load still initializes initial ads. - // - // The deferral is deliberately unconditional — every publisher, every - // page — even though only hydrating React publishers exhibit the #418 - // failure. Uniform behavior keeps one code path to reason about and - // avoids a framework-detection or config surface that must be kept - // truthful per publisher; the cost is that non-React pages also move the - // initial request from parse time to window load. The agreed follow-up - // (branch 958-adinit-hydration-chunk-gate, spec in docs/superpowers/ - // specs/2026-07-24-adinit-hydration-gate-design.md) narrows the gate to - // the Next.js hydration chunks with `load` as the can't-hang fallback, - // which recovers most of that latency without a new config surface. - // - // The bids payload is handed to the scheduler instead of being assigned - // here: an SPA navigation that committed while this document was still - // streaming has already replaced `tsjs.bids`, and an unconditional - // assignment would clobber the live route's bids with the stale SSR - // payload. Only when no scheduler exists at all (GPT integration active - // without its head bootstrap — not an expected deployment) does the script - // fall back to a plain assignment, where no SPA hook exists to race with. - format!( - "", - escaped - ) -} - -/// Prospective hard-cutover mark emitted at the bids/projection boundary. -/// -/// Task 19 inserts this already-tested fragment into the production boot path in -/// the same atomic switch that installs the matching first-display mark. -#[allow( - dead_code, - reason = "Task 16 prepares this fragment for the atomic Task 19 production switch" -)] -pub(crate) fn build_bids_script_performance_mark() -> &'static str { - "(function(){try{window.performance.mark(\"tsjs:bids-script\");}catch(_){}})();" -} - -/// Builds the client-facing JSON wire shape for one creative-opportunity slot. -/// -/// Shared verbatim by [`build_ad_slots_script`] (initial page render) and -/// [`handle_page_bids`] (SPA navigation) so the slot wire shape has a single -/// definition and the two paths cannot silently diverge. Property names match -/// what the client-side TSJS bundle expects: `gam_unit_path`, `div_id`, -/// `formats`, and `targeting`. Returns `None` when the slot's dynamic GAM unit -/// path exceeds its rendering limit. -pub(crate) fn build_slot_json( - slot: &crate::creative_opportunities::CreativeOpportunitySlot, - co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, - section: &str, -) -> Option { - let gam_path = slot.render_gam_unit_path(&co_config.gam_network_id, section)?; - let div_id = slot.resolved_div_id(); - let formats: Vec = slot - .formats - .iter() - .map(|f| serde_json::json!([f.width, f.height])) - .collect(); - let targeting: serde_json::Map = slot - .targeting - .iter() - .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) - .collect(); - Some(serde_json::json!({ - "id": slot.id, - "gam_unit_path": gam_path, - "div_id": div_id, - "formats": formats, - "targeting": targeting, - })) -} - /// Build the exact ordered GAM placement records carried by the browser projection. pub(crate) fn build_browser_slots_v1( matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], @@ -3865,31 +2884,6 @@ fn match_renderable_slots( .collect() } -/// Build the `tsjs.adSlots` `", - escaped - ) -} - /// Whether the content type requires processing (URL rewriting, HTML injection). /// /// Text-based and JavaScript/JSON responses are processable; binary types @@ -4155,9 +3149,8 @@ pub async fn handle_page_bids( // The [auction].enabled kill switch and a consent denial disable the entire // server-side ad stack. In those states the endpoint must return no slots, - // so the SPA hook does not assign `ts.adSlots` and call `adInit()` — - // otherwise the kill switch/consent gate would stop SSP calls but still let - // the client create/refresh GPT slots. Bot/prefetch requests, by contrast, + // so the coordinated runtime cannot create or refresh GPT placements. + // Bot/prefetch requests, by contrast, // keep their slot definitions (the placement structure is unchanged) but // skip the live auction, matching the existing bot/prefetch behaviour. let ad_stack_enabled = auction_enabled && consent_allows_auction; @@ -4337,7 +3330,6 @@ pub async fn handle_page_bids( mod tests { use std::future::Future as _; use std::io::{self, Read as _, Write as _}; - use std::sync::atomic::{AtomicUsize, Ordering}; use brotli::Decompressor; use brotli::enc::writer::CompressorWriter; @@ -4990,47 +3982,6 @@ mod tests { } } - struct ChunkedReader { - chunks: std::collections::VecDeque>, - read_count: Arc, - } - - impl ChunkedReader { - fn new(chunks: &[&[u8]], read_count: Arc) -> Self { - Self { - chunks: chunks.iter().map(|chunk| chunk.to_vec()).collect(), - read_count, - } - } - } - - 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) - } - } - - struct RecordingProcessor { - read_count: Arc, - body_close_processed_at: Arc, - } - - 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()) - } - } - fn gzip_encode(input: &[u8]) -> Vec { let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default()); encoder @@ -6659,186 +5610,6 @@ mod tests { ); } - #[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, - browser_slots_json: None, - 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, - browser_slots_json: None, - 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" - ); - } - #[test] fn unsupported_encoding_response_is_returned_unmodified() { assert_eq!( @@ -9335,25 +8106,14 @@ mod tests { #[cfg(test)] mod creative_opportunities_tests { - use super::super::{ - MatchedSlotsContext, build_ad_slots_script, build_auction_request, build_bid_map, - build_bids_script, build_bids_script_performance_mark, html_escape_for_script, - }; - use crate::auction::types::{ApsRendererV1, ApsTagType, Bid, BidRenderSourceV1, MediaType}; + 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; - 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 { @@ -9387,101 +8147,8 @@ mod tests { } } - 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(), - candidate_id: None, - candidate_provider: None, - renderer_reservation_id: None, - 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() { + 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()); @@ -9490,25 +8157,22 @@ mod tests { 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"); + let news = + build_browser_slots_v1(std::slice::from_ref(&slot), &config, "/news/article-123"); assert_eq!( - news["gam_unit_path"], "/99999/example/news", + news[0].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"); + let home = build_browser_slots_v1(std::slice::from_ref(&slot), &config, "/"); assert_eq!( - home["gam_unit_path"], "/99999/example/homepage", + home[0].gam_unit_path, "/99999/example/homepage", "root path should use section_root" ); } #[test] - fn build_slot_json_honours_configured_section_segment() { + 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(); @@ -9520,1239 +8184,55 @@ mod tests { 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"); + let news = build_browser_slots_v1( + std::slice::from_ref(&slot), + &config, + "/en/news/article-123", + ); assert_eq!( - news["gam_unit_path"], "/99999/example/news", + news[0].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"); + let locale_root = build_browser_slots_v1(std::slice::from_ref(&slot), &config, "/en"); assert_eq!( - locale_root["gam_unit_path"], "/99999/example/homepage", + locale_root[0].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" + fn auction_request_without_ec_id_omits_user_id_and_uses_non_ec_request_id() { + let slot = make_slot(); + let slots = [slot]; + let slots_ctx = MatchedSlotsContext { + matched_slots: &slots, + request_path_and_query: "/2024/01/my-article/?edition=fictional", + }; + let request_info = RequestInfo { + host: "publisher.example.com".to_string(), + scheme: "https".to_string(), + }; + + let request = build_auction_request( + &slots_ctx, + None, + &ConsentContext::default(), + &request_info, + "publisher.example.com", + Some("Mozilla/5.0"), ); - assert_eq!( - obj.get("nurl").and_then(|v| v.as_str()), - Some("https://ssp/win"), - "should include nurl" + + assert_eq!(request.user.id, None, "should not forward an EC user id"); + assert!( + request.id.starts_with("ts-req-"), + "should use a non-EC request id, got {}", + request.id ); assert_eq!( - obj.get("burl").and_then(|v| v.as_str()), - Some("https://ssp/bill"), - "should include burl" - ); - } - - #[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(BidRenderSourceV1::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 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(), - candidate_id: None, - candidate_provider: None, - renderer_reservation_id: None, - 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)]); - - 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()); - - let script = build_bids_script(&map); - assert!(!script.contains("")); - assert!(script.contains("\\u003C/script\\u003E")); - } - - #[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(), - candidate_id: None, - candidate_provider: None, - renderer_reservation_id: None, - 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" - ); - } - - #[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(), - candidate_id: None, - candidate_provider: None, - renderer_reservation_id: None, - 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" - ); - } - - #[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(), - candidate_id: None, - candidate_provider: None, - renderer_reservation_id: None, - 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" - ); - } - - #[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_performance_mark_is_exact_and_not_yet_wired() { - let fragment = build_bids_script_performance_mark(); - assert_eq!( - fragment, - "(function(){try{window.performance.mark(\"tsjs:bids-script\");}catch(_){}})();" - ); - assert!(!fragment.contains("__tsjsPerf")); - assert!( - !build_bids_script(&serde_json::Map::new()).contains("tsjs:bids-script"), - "Task 19 owns the coordinated production insertion" - ); - } - - #[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" - ); - } - - #[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" - ); - // 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!( - !script.contains("setTimeout"), - "should not retry adInit on a timer" - ); - } - - #[test] - fn auction_request_without_ec_id_omits_user_id_and_uses_non_ec_request_id() { - let slot = make_slot(); - let slots = [slot]; - let slots_ctx = MatchedSlotsContext { - matched_slots: &slots, - request_path_and_query: "/2024/01/my-article/?edition=fictional", - }; - let request_info = RequestInfo { - host: "publisher.example.com".to_string(), - scheme: "https".to_string(), - }; - - let request = build_auction_request( - &slots_ctx, - None, - &ConsentContext::default(), - &request_info, - "publisher.example.com", - Some("Mozilla/5.0"), - ); - - assert_eq!(request.user.id, None, "should not forward an EC user id"); - assert!( - request.id.starts_with("ts-req-"), - "should use a non-EC request id, got {}", - request.id - ); - assert_eq!( - request.publisher.page_url.as_deref(), - Some("https://publisher.example.com/2024/01/my-article/"), - "should preserve the page path but strip client query data for auction providers" + request.publisher.page_url.as_deref(), + Some("https://publisher.example.com/2024/01/my-article/"), + "should preserve the page path but strip client query data for auction providers" ); } @@ -10834,50 +8314,6 @@ 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 { @@ -10972,15 +8408,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 @@ -11305,10 +8737,9 @@ mod tests { 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(); diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts index 914a50301..c9ec1d4e2 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts @@ -49,6 +49,19 @@ function descriptor() { } test.describe("APS renderer v1 protocol", () => { + test("leaves every removed or unknown APS route unserved", async ({ + page, + }) => { + for (const path of [ + "/integrations/aps/renderer", + "/integrations/aps/renderer/v2", + "/integrations/aps/runner/v1.js", + ]) { + const response = await page.request.get(runtimeUrl(path)); + expect(response.status(), path).toBe(404); + } + }); + test("uses one port, reports ordered progress, and fails closed", async ({ page, }) => { 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 index f88f57f55..cc2c0b1c3 100644 --- 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 @@ -20,11 +20,7 @@ type HeapCheckpoint = | "afterSpaNavigation"; interface PerfApi { - addAdUnits(unit: { - code: string; - mediaTypes: { banner: { sizes: Array<[number, number]> } }; - }): void; - renderAdUnit(code: string): void; + requestAds(options?: { slots?: readonly string[] }): Promise; } function fixtureDocument(): string { @@ -33,10 +29,10 @@ function fixtureDocument(): string { TSJS deterministic performance fixture v1
', - 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 = 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 = 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 = 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(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(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(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(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(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 tsBid = trustedServerBid(adUnits[0]!); - expect(tsBid.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 tsBid = trustedServerBid(adUnits[0]!); - expect(tsBid.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 tsBid = trustedServerBid(adUnits[0]!); - expect(tsBid.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 unknown 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 = trustedServerBid(adUnits[0]!); - expect(tsBid0.params.zone).toBe('header'); - - const tsBid1 = trustedServerBid(adUnits[1]!); - 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 = trustedServerBid(adUnits[0]!); - 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 = trustedServerBid(adUnits[0]!); - 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 = trustedServerBid(adUnits[0]!); - expect(tsBid.params.zone).toBe('header'); - expect(tsBid.params.custom).toBe('keep'); - - delete (adUnits[0]!.mediaTypes.banner as { name?: string }).name; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - tsBid = trustedServerBid(adUnits[0]!); - 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 = []; - 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(['/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(); - }); -}); - -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 getTargeting = slot.getTargeting; - const originalGetTargeting = - typeof getTargeting === 'function' - ? (getTargeting as (key: string) => unknown[]).bind(slot) - : undefined; - 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[]; - } { - const lastCall = mockRequestBids.mock.calls[mockRequestBids.mock.calls.length - 1]; - const unit = lastCall?.[0]?.adUnits?.[0]; - if (!unit?.bids) throw new Error('expected the last Prebid request to contain bids'); - return unit as Record & { code?: string; bids: TestBid[] }; - } - - function refreshBidFromLastRequest(index = 0): TestBid & { params: Record } { - const bid = refreshAdUnitFromLastRequest().bids[index]; - if (!bid?.params) throw new Error(`expected refresh bid ${index} to contain params`); - return bid as TestBid & { params: Record }; - } - - 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 getSlotElementId = candidate?.getSlotElementId; - const elementId = - typeof getSlotElementId === 'function' - ? (getSlotElementId as () => string).call(candidate) - : undefined; - return elementId === unit.code || elementId === `${unit.code}-container`; - }); - if (slot) deliveryAdIds.set(slot, adId); - } - } - - opts?.bidsBackHandler?.(bidResponses, false, auctionId); - } - - 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); - - const mutableServerParams = firstRefreshBids[0]!.params as { - bidderParams: { - exampleServer: { placement: { rules: Array<{ label: string }>; sizes: number[] } }; - }; - }; - const mutableBrowserParams = firstRefreshBids[1]!.params as { - groups: Array<{ values: string[] }>; - }; - mutableServerParams.bidderParams.exampleServer.placement.rules[0]!.label = - 'changed-refresh-rule'; - mutableServerParams.bidderParams.exampleServer.placement.sizes.push(777); - mutableBrowserParams.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(refreshBidFromLastRequest().params).toEqual({ - bidderParams: { exampleServer: { placement: 'one' } }, - zone: 'example-zone-one', - }); - - pubads.refresh([slot]); - expect(refreshBidFromLastRequest().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(refreshBidFromLastRequest().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(refreshBidFromLastRequest().params.bidderParams).toEqual({ - exampleServer: { placement: 'one' }, - }); - pubads.refresh([slotTwo]); - expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ - exampleServer: { placement: 'two' }, - }); - pubads.refresh([globalSlot]); - expect(refreshBidFromLastRequest().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(refreshBidFromLastRequest().params).toEqual({ bidderParams: {} }); - pubads.refresh([slots[1]!]); - expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: {} }); - pubads.refresh([slots[2]!]); - expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ - exampleServer: { placement: codes[2]! }, - }); - - (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit(); - pubads.refresh([slots[2]!]); - expect(refreshBidFromLastRequest().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(refreshBidFromLastRequest().params).toEqual({ bidderParams: {} }); - pubads.refresh([activeSlot]); - expect(refreshBidFromLastRequest().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: [ - { - id: 'example-covered-two', - div_id: 'example-covered-two', - gam_unit_path: '/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[] | null, - customSlotMatching?: () => (slot: unknown) => boolean - ) => 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 = trustedServerBid(adUnits[0]!); - 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 = trustedServerBid(adUnits[0]!); - // 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 = trustedServerBid(adUnits[0]!); - 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 = trustedServerBid(adUnits[0]!); - 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 = trustedServerBid(adUnits[0]!); - 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(); - }); -}); 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 f23e86c25..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 @@ -4,7 +4,7 @@ import { disposeSourcepointConsentMirror, initializeSourcepointConsentMirror, mirrorSourcepointConsent, -} from '../../../src/integrations/sourcepoint'; +} from '../../../src/integrations/sourcepoint/consent_mirror'; import { createSourcepointRuntime } from '../../../src/integrations/sourcepoint/module'; describe('Sourcepoint integration initialization', () => { 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 ec969308c..bb8c93ede 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. ::: diff --git a/docs/guide/creative-processing.md b/docs/guide/creative-processing.md index abaf617c9..9e56ead6c 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -96,8 +96,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 ``/`