From ccac679ead54e0e84749bf5bdd411cb12588053c Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Sat, 28 Feb 2026 17:47:58 -0800 Subject: [PATCH 01/22] Paywalls RTD Adapter: implement paywallsRtdProvider module - Register RTD submodule 'paywalls' with init, getBidRequestData, getTargetingData - ORTB2 split: site.ext.vai (iss, aud, dom, kid, assertion_jws) + user.ext.vai (vat, act) - Dynamic vai.js injection via loadExternalScript with poll/hook/timeout - Graceful degradation: never blocks the auction - 22 unit tests covering all acceptance criteria - Added to approvedLoadExternalScriptPaths whitelist --- modules/paywallsRtdProvider.js | 293 +++++++++++++++ .../eslint/approvedLoadExternalScriptPaths.js | 1 + test/spec/modules/paywallsRtdProvider_spec.js | 345 ++++++++++++++++++ 3 files changed, 639 insertions(+) create mode 100644 modules/paywallsRtdProvider.js create mode 100644 test/spec/modules/paywallsRtdProvider_spec.js diff --git a/modules/paywallsRtdProvider.js b/modules/paywallsRtdProvider.js new file mode 100644 index 0000000000..f09c19ee8e --- /dev/null +++ b/modules/paywallsRtdProvider.js @@ -0,0 +1,293 @@ +/** + * This module adds the Paywalls VAI (Validated Actor Inventory) provider + * to the real time data module. + * + * VAI classifies page impressions by actor type (vat) and confidence tier + * (act), producing a cryptographically signed assertion. The RTD submodule + * automates VAI loading, timing, and ORTB2 injection. + * + * ORTB2 placement (canonical split): + * site.ext.vai — { iss, aud, dom, kid, assertion_jws } + * user.ext.vai — { vat, act } + * + * @module modules/paywallsRtdProvider + * @requires module:modules/realTimeData + * @see https://docs.paywalls.net/publishers/vai + */ + +import {submodule} from '../src/hook.js'; +import {mergeDeep, logInfo, logWarn} from '../src/utils.js'; +import {loadExternalScript} from '../src/adloader.js'; +import {MODULE_TYPE_RTD} from '../src/activities/modules.js'; +import {getStorageManager} from '../src/storageManager.js'; + +/** + * @typedef {import('../modules/rtdModule/index.js').RtdSubmodule} RtdSubmodule + */ + +const LOG_PREFIX = '[PaywallsRtd] '; +const MODULE_NAME = 'realTimeData'; + +export const SUBMODULE_NAME = 'paywalls'; +export const DEFAULT_SCRIPT_URL = '/pw/vai.js'; +export const VAI_WINDOW_KEY = '__PW_VAI__'; +export const VAI_HOOK_KEY = '__PW_VAI_HOOK__'; +export const VAI_LS_KEY = '__pw_vai__'; + +const DEFAULT_WAIT_FOR_IT = 100; + +// Cached VAI payload from init (for early detection) +let cachedVai = null; + +export const storage = getStorageManager({moduleType: MODULE_TYPE_RTD, moduleName: SUBMODULE_NAME}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Check whether a VAI payload is present and unexpired. + * @param {object|null} vai + * @returns {boolean} + */ +function isValid(vai) { + if (!vai || typeof vai !== 'object') return false; + if (!vai.vat || !vai.act) return false; + if (vai.exp && vai.exp < Math.floor(Date.now() / 1000)) return false; + return true; +} + +/** + * Attempt to read a VAI payload from window or localStorage. + * @returns {object|null} + */ +export function getVaiPayload() { + // 1. Check window global (set by vai.js + + + + + + + + +

Paywalls RTD Provider — Integration Test

+ +

Test Results

+
⏳ Waiting for Prebid to load...
+
⏳ site.ext.vai
+
⏳ user.ext.vai
+
⏳ GAM targeting keys
+
⏳ Timing
+
⏳ Running...
+ +

Ad Slot (test-div)

+
+
+ (Ad slot — no real bids expected in test) +
+
+ +

Debug

+

Open DevTools console for full ORTB2 and targeting output. Prebid debug: true is enabled.

+ + From a4f29c677722469b7b6070ccba8dde84b97e25d6 Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Sat, 28 Feb 2026 23:01:15 -0800 Subject: [PATCH 03/22] Paywalls RTD Adapter: add module documentation (paywallsRtdProvider.md) - Overview, build instructions, configuration parameters - ORTB2 split placement: site.ext.vai (provenance) + user.ext.vai (classification) - GAM targeting keys (vai_vat, vai_act) - Activity Controls, privacy section, how-it-works - Testing instructions for unit and integration tests --- modules/paywallsRtdProvider.md | 188 +++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 modules/paywallsRtdProvider.md diff --git a/modules/paywallsRtdProvider.md b/modules/paywallsRtdProvider.md new file mode 100644 index 0000000000..c9c7d7fb88 --- /dev/null +++ b/modules/paywallsRtdProvider.md @@ -0,0 +1,188 @@ +# Paywalls RTD Provider + +## Overview + +**Module Name:** Paywalls RTD Provider +**Module Type:** RTD Provider +**Maintainer:** [engineering@paywalls.net](mailto:engineering@paywalls.net) + +## Description + +The Paywalls RTD module integrates [VAI (Validated Actor Inventory)](https://docs.paywalls.net/publishers/vai) into Prebid.js. VAI classifies page impressions by **actor type** (`vat`) and **confidence tier** (`act`), producing a cryptographically signed assertion that SSPs can independently verify. + +The module automates VAI loading, timing, and signal injection: + +- **ORTB2 enrichment** — VAI signals are split across `site.ext.vai` (domain provenance) and `user.ext.vai` (actor classification), available to all ORTB2-native bid adapters. +- **GAM targeting** — `vai_vat` and `vai_act` key-value pairs are set per ad unit for Google Ad Manager line item targeting. +- **Graceful degradation** — if VAI is unavailable or times out, the auction proceeds normally without enrichment. + +## Build Instructions + +Compile the Paywalls RTD module into your Prebid build: + +```sh +gulp build --modules=rtdModule,paywallsRtdProvider +``` + +> The global RTD module (`rtdModule`) is a prerequisite. + +## Configuration + +```javascript +pbjs.setConfig({ + realTimeData: { + auctionDelay: 500, + dataProviders: [ + { + name: 'paywalls', + waitForIt: true, + params: { + scriptUrl: '/pw/vai.js' + } + } + ] + } +}); +``` + +### Parameters + +| Name | Type | Scope | Description | Default | +|---------------------|-----------|----------|--------------------------------------------------------|------------------| +| `name` | `String` | Required | Must be `'paywalls'` | — | +| `waitForIt` | `Boolean` | Optional | Should be `true` when `auctionDelay` is set | `false` | +| `params` | `Object` | Optional | Provider configuration | `{}` | +| `params.scriptUrl` | `String` | Optional | URL of the VAI loader script | `'/pw/vai.js'` | +| `params.waitForIt` | `Number` | Optional | Max ms to wait for VAI before releasing the auction | `100` | + +### Hosting Modes + +VAI supports two hosting modes for the loader script: + +- **Paywalls-hosted** (recommended): The script is served from the Paywalls CDN. Set `scriptUrl` to the Paywalls endpoint (e.g., `'https://cdn.paywalls.net/pw/vai.js'`). +- **Publisher-hosted**: The script is served from the publisher's own domain via a reverse proxy. Use a relative path (e.g., `'/pw/vai.js'`). + +In both cases, `vai.js` makes a same-origin or CORS request to fetch `vai.json`, which contains the signed assertion. + +## ORTB2 Output + +VAI signals are placed in the global ORTB2 config using a canonical split placement: + +### `site.ext.vai` — Domain Provenance + +Fields that describe the assertion context (who issued it, for which domain): + +```json +{ + "site": { + "ext": { + "vai": { + "iss": "https://paywalls.net", + "aud": "vai", + "dom": "example.com", + "kid": "2026-02-a1b2c3", + "assertion_jws": "eyJhbGciOiJFZERTQSIs..." + } + } + } +} +``` + +| Field | Description | +|-----------------|--------------------------------------------------| +| `iss` | Issuer — always `https://paywalls.net` | +| `aud` | Audience — always `vai` | +| `dom` | Domain the assertion covers | +| `kid` | Key ID for JWS verification via JWKS endpoint | +| `assertion_jws` | Full JWS (compact serialization) for SSP verification | + +### `user.ext.vai` — Actor Classification + +Fields that describe the classified actor: + +```json +{ + "user": { + "ext": { + "vai": { + "vat": "HUMAN", + "act": "ACT-1" + } + } + } +} +``` + +| Field | Description | +|-------|--------------------------------------------------------------| +| `vat` | Validated Actor Type — `HUMAN`, `AUTOMATED`, or `UNCERTAIN` | +| `act` | Actor Confidence Tier — `ACT-1` (highest) through `ACT-4` | + +## GAM Targeting + +The module sets key-value pairs on every ad unit for Google Ad Manager targeting: + +| Key | Example Value | Description | +|-----------|---------------|---------------------| +| `vai_vat` | `HUMAN` | Actor type | +| `vai_act` | `ACT-1` | Confidence tier | + +These are available via `pbjs.getAdserverTargeting()` and are compatible with standard GPT integration. + +## Activity Controls + +Publishers can restrict which activities the Paywalls module is allowed to perform. The module uses `loadExternalScript`, which requires the `fetchBids` activity to allow the `paywalls` module: + +```javascript +pbjs.setConfig({ + allowActivities: { + fetchBids: { + default: true, + rules: [ + { + condition: function (params) { + return params.componentName === 'paywalls'; + }, + allow: true + } + ] + } + } +}); +``` + +## Privacy + +- **No user identifiers**: VAI does not collect, store, or transmit user IDs, cookies, or fingerprints. +- **No PII**: The classification is based on aggregate session-level behavioral signals, not personal data. +- **Browser-side only**: All signal extraction runs in the browser; no data leaves the page except the classification result. +- **Signed assertions**: SSPs can independently verify the `assertion_jws` via the JWKS endpoint (`/pw/jwks.json`), ensuring the classification has not been tampered with. + +## How It Works + +1. **`init()`** — Checks `window.__PW_VAI__` and `localStorage` for an existing VAI payload. If present and unexpired, caches it for immediate use. +2. **`getBidRequestData()`** — If cached VAI exists, merges ORTB2 and calls back immediately (fast path). Otherwise, injects `vai.js` via `loadExternalScript` and polls/hooks for the result until `waitForIt` ms elapse (slow path). On timeout, calls back without enrichment. +3. **`getTargetingData()`** — Returns `{ vai_vat, vai_act }` for each ad unit from the current VAI payload. + +## Testing + +Run unit tests: + +```sh +gulp test --nolint --file test/spec/modules/paywallsRtdProvider_spec.js +``` + +Run the integration test page: + +```sh +gulp serve-fast --modules=rtdModule,paywallsRtdProvider,appnexusBidAdapter +# Open http://localhost:9999/integrationExamples/gpt/paywallsRtdProvider_example.html +# Append ?real to test with real VAI (requires cloud-api on :8080) +# Append ?degrade to test graceful degradation +``` + +## Links + +- [VAI Documentation](https://docs.paywalls.net/publishers/vai) +- [Prebid RTD Module Documentation](https://docs.prebid.org/dev-docs/add-rtd-submodule.html) +- [How Bid Adapters Should Read First Party Data](https://docs.prebid.org/features/firstPartyData.html#how-bid-adapters-should-read-first-party-data) From c23f988cc0bfc48e55cddd52b2eeaca47da6aa5d Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Sun, 1 Mar 2026 09:34:05 -0800 Subject: [PATCH 04/22] Paywalls RTD Adapter: update hosting modes in docs - Publisher-hosted is preferred (same-origin, no CORS, dom claim match) - Paywalls-hosted uses paywalls.net (not cdn.paywalls.net) - Clarify CDN/server integration vs reverse proxy --- modules/paywallsRtdProvider.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/paywallsRtdProvider.md b/modules/paywallsRtdProvider.md index c9c7d7fb88..bcc80bc754 100644 --- a/modules/paywallsRtdProvider.md +++ b/modules/paywallsRtdProvider.md @@ -59,14 +59,14 @@ pbjs.setConfig({ VAI supports two hosting modes for the loader script: -- **Paywalls-hosted** (recommended): The script is served from the Paywalls CDN. Set `scriptUrl` to the Paywalls endpoint (e.g., `'https://cdn.paywalls.net/pw/vai.js'`). -- **Publisher-hosted**: The script is served from the publisher's own domain via a reverse proxy. Use a relative path (e.g., `'/pw/vai.js'`). +- **Publisher-hosted** (preferred): The script is served from the publisher's own domain via a CDN or server integration. Use the default relative path `'/pw/vai.js'`. This keeps requests same-origin, avoids CORS, and ensures the assertion's `dom` claim matches the inventory domain. +- **Paywalls-hosted**: The script is served from `https://paywalls.net/pw/vai.js`. Set `scriptUrl` to the full URL. This mode requires paywalls.net configuration before usage. **Note** The domain provenance claim (`dom`) will reflect `paywalls.net` rather than the inventory domain, which may affect SSP verification and buyer trust. -In both cases, `vai.js` makes a same-origin or CORS request to fetch `vai.json`, which contains the signed assertion. +In both cases, `vai.js` makes a request to fetch `vai.json`, which contains the signed assertion. The publisher-hosted mode is same-origin; the Paywalls-hosted mode is cross-origin (CORS). ## ORTB2 Output -VAI signals are placed in the global ORTB2 config using a canonical split placement: +VAI signals are placed in the global ORTB2 within the `site` and `user` sections: ### `site.ext.vai` — Domain Provenance @@ -94,7 +94,7 @@ Fields that describe the assertion context (who issued it, for which domain): | `aud` | Audience — always `vai` | | `dom` | Domain the assertion covers | | `kid` | Key ID for JWS verification via JWKS endpoint | -| `assertion_jws` | Full JWS (compact serialization) for SSP verification | +| `assertion_jws` | Full JWS (compact serialization) for SSP and DSP verification | ### `user.ext.vai` — Actor Classification From aa5472b728b1f3deb693709a7550ddca126a70b7 Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Sun, 1 Mar 2026 09:39:33 -0800 Subject: [PATCH 05/22] Paywalls RTD Adapter: fix activity controls docs - Correct activity name: loadExternalScript (not fetchBids) - Update example default to false (realistic scenario) - Editorial refinements --- modules/paywallsRtdProvider.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/paywallsRtdProvider.md b/modules/paywallsRtdProvider.md index bcc80bc754..e92e21774f 100644 --- a/modules/paywallsRtdProvider.md +++ b/modules/paywallsRtdProvider.md @@ -131,13 +131,13 @@ These are available via `pbjs.getAdserverTargeting()` and are compatible with st ## Activity Controls -Publishers can restrict which activities the Paywalls module is allowed to perform. The module uses `loadExternalScript`, which requires the `fetchBids` activity to allow the `paywalls` module: +Publishers can restrict which activities the Paywalls module is allowed to perform. The module uses `loadExternalScript` to inject `vai.js`, which is gated by the `loadExternalScript` activity. If your activity configuration denies this by default, explicitly allow it for the `paywalls` component: ```javascript pbjs.setConfig({ allowActivities: { - fetchBids: { - default: true, + loadExternalScript: { + default: false, rules: [ { condition: function (params) { @@ -156,7 +156,7 @@ pbjs.setConfig({ - **No user identifiers**: VAI does not collect, store, or transmit user IDs, cookies, or fingerprints. - **No PII**: The classification is based on aggregate session-level behavioral signals, not personal data. - **Browser-side only**: All signal extraction runs in the browser; no data leaves the page except the classification result. -- **Signed assertions**: SSPs can independently verify the `assertion_jws` via the JWKS endpoint (`/pw/jwks.json`), ensuring the classification has not been tampered with. +- **Signed assertions**: SSPs can independently verify the `assertion_jws` via the JWKS endpoint pulled from the JWS header (typically `https://example.com/pw/jwks.json`), ensuring the classification has not been tampered with. ## How It Works From bf397fd19ae64891cfda607ca05f15dc0fc4cb5d Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Sun, 1 Mar 2026 11:29:43 -0800 Subject: [PATCH 06/22] Paywalls Analytics Adapter: emit VAI vat/act --- .../gpt/paywallsAnalyticsAdapter_example.html | 167 +++++++ modules/paywallsAnalyticsAdapter.js | 245 ++++++++++ modules/paywallsAnalyticsAdapter.md | 173 +++++++ modules/paywallsRtdProvider.md | 10 +- .../eslint/approvedLoadExternalScriptPaths.js | 1 + .../modules/paywallsAnalyticsAdapter_spec.js | 453 ++++++++++++++++++ 6 files changed, 1045 insertions(+), 4 deletions(-) create mode 100644 integrationExamples/gpt/paywallsAnalyticsAdapter_example.html create mode 100644 modules/paywallsAnalyticsAdapter.js create mode 100644 modules/paywallsAnalyticsAdapter.md create mode 100644 test/spec/modules/paywallsAnalyticsAdapter_spec.js diff --git a/integrationExamples/gpt/paywallsAnalyticsAdapter_example.html b/integrationExamples/gpt/paywallsAnalyticsAdapter_example.html new file mode 100644 index 0000000000..aa13b6e2e4 --- /dev/null +++ b/integrationExamples/gpt/paywallsAnalyticsAdapter_example.html @@ -0,0 +1,167 @@ + + + + Paywalls Analytics Adapter — E2E Test + + + + + + + + + +

Paywalls Analytics Adapter — Integration Test

+

Verifies vai_vat and vai_act are emitted on each auction.

+ +
⏳ Waiting for auction...
+
⏳ vai_vat
+
⏳ vai_act
+
⏳ Only vat/act keys
+
⏳ Running...
+ +
(ad slot)
+ +

+ ?degrade — test with VAI unavailable  |  + ?real — use real VAI from 127.0.0.1:8080 +

+ + diff --git a/modules/paywallsAnalyticsAdapter.js b/modules/paywallsAnalyticsAdapter.js new file mode 100644 index 0000000000..91a26c7503 --- /dev/null +++ b/modules/paywallsAnalyticsAdapter.js @@ -0,0 +1,245 @@ +/** + * Paywalls VAI Analytics Adapter + * + * Emits VAI (Validated Actor Inventory) classification — vat and act — + * on each auction to the publisher's analytics pipeline (GA4, GTM + * dataLayer, or custom callback). Publishers use their existing + * reporting stack (GAM, GA4, warehouse) to slice any metric by traffic + * classification. No bid-level aggregation is done here; the RTD + * module already injects VAI into ORTB2 so SSPs and GAM can report + * on it natively. + * + * @module modules/paywallsAnalyticsAdapter + * @see https://docs.paywalls.net/publishers/vai + */ + +import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; +import adapterManager from '../src/adapterManager.js'; +import {EVENTS} from '../src/constants.js'; +import {logInfo, logWarn} from '../src/utils.js'; +import {loadExternalScript} from '../src/adloader.js'; +import {MODULE_TYPE_ANALYTICS} from '../src/activities/modules.js'; + +const ADAPTER_CODE = 'paywalls'; +const LOG_PREFIX = '[PaywallsAnalytics] '; + +export const DEFAULT_SCRIPT_URL = '/pw/vai.js'; +export const VAI_WINDOW_KEY = '__PW_VAI__'; + +const {AUCTION_END} = EVENTS; + +// Track which auctions we've already emitted for (de-dup) +let emittedAuctions = {}; + +// --------------------------------------------------------------------------- +// Config (set during enableAnalytics) +// --------------------------------------------------------------------------- + +export let adapterConfig = { + output: 'callback', // 'gtag' | 'dataLayer' | 'callback' + scriptUrl: DEFAULT_SCRIPT_URL, + samplingRate: 1.0, + callback: null, +}; + +// Whether this page session is sampled in +let sampledIn = true; + +// Whether VAI script injection has been attempted +let vaiInjected = false; + +// --------------------------------------------------------------------------- +// VAI loading +// --------------------------------------------------------------------------- + +/** + * Read VAI classification from the window global. + * @returns {{ vat: string, act: string } | null} + */ +export function getVaiClassification() { + const vai = window[VAI_WINDOW_KEY]; + if (vai && typeof vai === 'object' && vai.vat && vai.act) { + return {vat: vai.vat, act: vai.act}; + } + return null; +} + +/** + * Ensure VAI is available on the page. + * If window.__PW_VAI__ is not yet present, inject the script. + * @param {string} scriptUrl + */ +export function ensureVai(scriptUrl) { + if (window[VAI_WINDOW_KEY]) { + logInfo(LOG_PREFIX + 'VAI already present. vat=' + window[VAI_WINDOW_KEY].vat); + return; + } + if (vaiInjected) { + return; + } + vaiInjected = true; + logInfo(LOG_PREFIX + 'injecting vai.js from ' + scriptUrl); + try { + loadExternalScript(scriptUrl, MODULE_TYPE_ANALYTICS, ADAPTER_CODE); + } catch (e) { + logWarn(LOG_PREFIX + 'failed to load vai.js:', e); + } +} + +// --------------------------------------------------------------------------- +// Metrics computation +// --------------------------------------------------------------------------- + +/** + * Build the KVP object emitted per auction. + * Only contains VAI classification — vat and act. + * UNKNOWN values signal that VAI was not available (script failed to + * load, timed out, or returned an invalid response). + * @returns {object} The KVP metrics object + */ +export function computeMetrics() { + const vai = getVaiClassification(); + return { + vai_vat: vai ? vai.vat : 'UNKNOWN', + vai_act: vai ? vai.act : 'UNKNOWN', + }; +} + +// --------------------------------------------------------------------------- +// Output emission +// --------------------------------------------------------------------------- + +/** + * Emit metrics via the configured output mode. + * @param {object} kvps The metrics key-value pairs + */ +export function emitMetrics(kvps) { + const output = adapterConfig.output; + + logInfo(LOG_PREFIX + 'emitting metrics via ' + output + ':', kvps); + + switch (output) { + case 'gtag': + if (typeof window.gtag === 'function') { + window.gtag('event', 'vai_auction', kvps); + } else { + logWarn(LOG_PREFIX + 'gtag not found on window — metrics not sent'); + } + break; + + case 'dataLayer': + window.dataLayer = window.dataLayer || []; + window.dataLayer.push(Object.assign({event: 'vai_auction'}, kvps)); + break; + + case 'callback': + if (typeof adapterConfig.callback === 'function') { + try { + adapterConfig.callback(kvps); + } catch (e) { + logWarn(LOG_PREFIX + 'callback error:', e); + } + } else { + logWarn(LOG_PREFIX + 'output is "callback" but no callback function provided'); + } + break; + + default: + logWarn(LOG_PREFIX + 'unknown output mode: ' + output); + } +} + +// --------------------------------------------------------------------------- +// Event tracking +// --------------------------------------------------------------------------- + +/** + * Handle a tracked Prebid event. + * We only care about AUCTION_END — emit classification once per auction. + * @param {{ eventType: string, args: object }} param + */ +function handleEvent({eventType, args}) { + if (!sampledIn) return; + if (eventType !== AUCTION_END) return; + + const auctionId = args && args.auctionId; + if (!auctionId) return; + + // De-dup: only emit once per auction + if (emittedAuctions[auctionId]) return; + emittedAuctions[auctionId] = true; + + const kvps = computeMetrics(); + emitMetrics(kvps); +} + +// --------------------------------------------------------------------------- +// Adapter setup +// --------------------------------------------------------------------------- + +const paywallsAnalytics = Object.assign( + adapter({analyticsType: 'bundle'}), + { + track: handleEvent, + } +); + +// Save original enableAnalytics +paywallsAnalytics.originEnableAnalytics = paywallsAnalytics.enableAnalytics; + +/** + * Override enableAnalytics to parse config and inject VAI. + * @param {object} config + */ +paywallsAnalytics.enableAnalytics = function (config) { + const options = (config && config.options) || {}; + + // Parse config + adapterConfig.output = options.output || 'callback'; + adapterConfig.scriptUrl = options.scriptUrl || DEFAULT_SCRIPT_URL; + adapterConfig.samplingRate = typeof options.samplingRate === 'number' ? options.samplingRate : 1.0; + adapterConfig.callback = typeof options.callback === 'function' ? options.callback : null; + + // Sampling decision + sampledIn = Math.random() < adapterConfig.samplingRate; + if (!sampledIn) { + logInfo(LOG_PREFIX + 'page excluded by sampling (rate=' + adapterConfig.samplingRate + ')'); + } + + // Reset state + emittedAuctions = {}; + vaiInjected = false; + + // Ensure VAI is on the page + ensureVai(adapterConfig.scriptUrl); + + // Call original enable (wires up event listeners) + paywallsAnalytics.originEnableAnalytics(config); +}; + +adapterManager.registerAnalyticsAdapter({ + adapter: paywallsAnalytics, + code: ADAPTER_CODE, +}); + +/** + * Reset internal state — used by unit tests. + * @param {object} [overrides] + * @param {boolean} [overrides.sampledIn] + */ +export function resetForTesting(overrides) { + emittedAuctions = {}; + vaiInjected = false; + sampledIn = true; + adapterConfig.output = 'callback'; + adapterConfig.scriptUrl = DEFAULT_SCRIPT_URL; + adapterConfig.samplingRate = 1.0; + adapterConfig.callback = null; + if (overrides) { + if (typeof overrides.sampledIn === 'boolean') { + sampledIn = overrides.sampledIn; + } + } +} + +export default paywallsAnalytics; diff --git a/modules/paywallsAnalyticsAdapter.md b/modules/paywallsAnalyticsAdapter.md new file mode 100644 index 0000000000..a9a7927b4c --- /dev/null +++ b/modules/paywallsAnalyticsAdapter.md @@ -0,0 +1,173 @@ +# Overview + +**Module Name:** Paywalls Analytics Adapter + +**Module Type:** Analytics Adapter + +**Maintainer:** [engineering@paywalls.net](mailto:engineering@paywalls.net) + + +## Description + +The Paywalls Analytics Adapter emits [VAI (Validated Actor Inventory)](https://docs.paywalls.net/publishers/vai) classification on each Prebid auction. VAI helps publishers distinguish **human traffic** and **AI agents** from **non-human automation** (sharing/preview bots, search crawlers, AI training, etc.) so they can make better-informed economic decisions. + +In practice, this enables publishers to: + +- **Segment and analyze performance** by traffic class (yield, fill, viewability, buyer outcomes) in their existing analytics stack (GA4, GTM / dataLayer, or a custom callback) +- **Route supply to appropriate demand** (e.g., prioritize premium demand paths for higher-confidence human traffic, apply different floors/controls to lower-confidence or non-human traffic) + +This can improve monetization efficiency and help buyers receive inventory that matches their quality requirements. + +Two key-value pairs are emitted per auction: + +| Key | Example Value | Description | +|-----------|---------------|--------------------------------------------------------------| +| `vai_vat` | `HUMAN` | Validated Actor Type — one of `HUMAN`, `AI_AGENT`, `SHARING`, `OTHER` | +| `vai_act` | `ACT-1` | Actor Confidence Tier — one of `ACT-1`, `ACT-2`, `ACT-3` | + +If VAI is unavailable (script failed to load, timed out, or returned an invalid response), both values are `UNKNOWN`. + +> **No bid-level aggregation is performed.** The companion [Paywalls RTD Provider](paywallsRtdProvider.md) injects VAI into ORTB2 (`site.ext.vai`, `user.ext.vai`) and GAM targeting (`vai_vat`, `vai_act`), so SSPs, DSPs, GAM, and warehouse pipelines already have the signals needed to aggregate natively. The analytics adapter simply confirms classification is reaching the page and routes it to the publisher's analytics tool of choice. + +## Build Instructions + +```sh +gulp build --modules=rtdModule,paywallsRtdProvider,paywallsAnalyticsAdapter +``` + +The RTD provider is not strictly required, but is recommended — it handles ORTB2 enrichment and GAM targeting. The analytics adapter independently reads the same `window.__PW_VAI__` global. + +## Configuration + +```javascript +pbjs.enableAnalytics([{ + provider: 'paywalls', + options: { + output: 'callback', // 'gtag' | 'dataLayer' | 'callback' + scriptUrl: '/pw/vai.js', // VAI loader URL + samplingRate: 1.0, // 0.0–1.0 + callback: function (metrics) { + console.log(metrics); + // { vai_vat: 'HUMAN', vai_act: 'ACT-1' } + } + } +}]); +``` + +### Parameters + +| Name | Type | Scope | Description | Default | +|------------------------|------------|----------|--------------------------------------------------------|------------------| +| `provider` | `String` | Required | Must be `'paywalls'` | — | +| `options` | `Object` | Optional | Adapter configuration | `{}` | +| `options.output` | `String` | Optional | Output mode: `'gtag'`, `'dataLayer'`, or `'callback'` | `'callback'` | +| `options.scriptUrl` | `String` | Optional | URL of the VAI loader script | `'/pw/vai.js'` | +| `options.samplingRate` | `Number` | Optional | Fraction of page views that emit analytics (0.0–1.0) | `1.0` | +| `options.callback` | `Function` | Optional | Called with the metrics object when `output` is `'callback'` | `null` | + +### Hosting Modes + +VAI supports two hosting modes for the loader script: + +- **Publisher-hosted** (preferred): The script is served from the publisher's own domain via a CDN or server integration. Use the default relative path `'/pw/vai.js'`. This keeps requests same-origin, avoids CORS, and ensures the assertion's `dom` claim matches the inventory domain. +- **Paywalls-hosted**: The script is served from `https://paywalls.net/pw/vai.js`. Set `scriptUrl` to the full URL. This mode requires paywalls.net configuration before usage. **Note** The domain provenance claim (`dom`) will reflect `paywalls.net` rather than the inventory domain, which may affect SSP verification and buyer trust. + +In both cases, `vai.js` makes a request to fetch `vai.json`, which contains the signed assertion. The publisher-hosted mode is same-origin; the Paywalls-hosted mode is cross-origin (CORS). + +## Output Modes + +### `gtag` — Google Analytics 4 + +Fires a GA4 event via the global `gtag()` function: + +```javascript +gtag('event', 'vai_auction', { vai_vat: 'HUMAN', vai_act: 'ACT-1' }); +``` + +Requires the GA4 snippet (`gtag.js`) to be loaded on the page. + +### `dataLayer` — Google Tag Manager + +Pushes to the GTM `dataLayer` array: + +```javascript +window.dataLayer.push({ + event: 'vai_auction', + vai_vat: 'HUMAN', + vai_act: 'ACT-1' +}); +``` + +The array is created automatically if it does not exist. In GTM, create a Custom Event trigger on `vai_auction` to route the data to any tag. + +### `callback` — Custom Function + +Calls the provided function with the metrics object: + +```javascript +callback({ vai_vat: 'HUMAN', vai_act: 'ACT-1' }); +``` + +Use this for custom pipelines, data warehouses, or any destination not covered by the built-in modes. + +## Sampling + +Set `samplingRate` to a value between `0.0` and `1.0` to control cost. The decision is made once per page load — all auctions on that page either emit or don't. + +```javascript +options: { samplingRate: 0.1 } // emit on ~10% of page views +``` + +## De-duplication + +The adapter emits **once per auction ID**. If Prebid fires multiple `AUCTION_END` events for the same auction (e.g. due to race conditions), only the first is processed. + +## Activity Controls + +The adapter uses `loadExternalScript` to inject `vai.js`. If your activity configuration restricts external scripts, allow the `paywalls` component: + +```javascript +pbjs.setConfig({ + allowActivities: { + loadExternalScript: { + default: false, + rules: [{ + condition: function (params) { + return params.componentName === 'paywalls'; + }, + allow: true + }] + } + } +}); +``` + +## Privacy + +- **No user identifiers**: VAI does not collect, store, or transmit user IDs, cookies, or fingerprints. +- **No PII**: The classification is based on aggregate session-level behavioral signals, not personal data. +- **Browser-side only**: All signal extraction runs in the browser; no data leaves the page except the classification result. +- **Signed assertions**: SSPs can independently verify the `assertion_jws` via the JWKS endpoint pulled from the JWS header (typically `https://example.com/pw/jwks.json`), ensuring the classification has not been tampered with. + +## Testing + +Run unit tests: + +```sh +gulp test --nolint --file test/spec/modules/paywallsAnalyticsAdapter_spec.js +``` + +Run the integration test page: + +```sh +gulp serve-fast --modules=rtdModule,paywallsRtdProvider,paywallsAnalyticsAdapter,appnexusBidAdapter +# Open http://localhost:9999/integrationExamples/gpt/paywallsAnalyticsAdapter_example.html +# Append ?real to test with real VAI (requires cloud-api on :8080) +# Append ?degrade to test graceful degradation (VAI unavailable) +``` + +## Links + +- [VAI Documentation](https://paywalls.net/docs/publishers/vai) +- [Paywalls RTD Provider](paywallsRtdProvider.md) +- [Prebid Analytics Adapter Documentation](https://docs.prebid.org/dev-docs/integrate-with-the-prebid-analytics-api.html) diff --git a/modules/paywallsRtdProvider.md b/modules/paywallsRtdProvider.md index e92e21774f..f4649f4b21 100644 --- a/modules/paywallsRtdProvider.md +++ b/modules/paywallsRtdProvider.md @@ -3,12 +3,14 @@ ## Overview **Module Name:** Paywalls RTD Provider + **Module Type:** RTD Provider + **Maintainer:** [engineering@paywalls.net](mailto:engineering@paywalls.net) ## Description -The Paywalls RTD module integrates [VAI (Validated Actor Inventory)](https://docs.paywalls.net/publishers/vai) into Prebid.js. VAI classifies page impressions by **actor type** (`vat`) and **confidence tier** (`act`), producing a cryptographically signed assertion that SSPs can independently verify. +The Paywalls RTD module integrates [VAI (Validated Actor Inventory)](https://paywalls.net/docs/publishers/vai) into Prebid.js. VAI classifies page impressions by **actor type** (`vat`) and **confidence tier** (`act`), producing a cryptographically signed assertion that SSPs can independently verify. The module automates VAI loading, timing, and signal injection: @@ -115,8 +117,8 @@ Fields that describe the classified actor: | Field | Description | |-------|--------------------------------------------------------------| -| `vat` | Validated Actor Type — `HUMAN`, `AUTOMATED`, or `UNCERTAIN` | -| `act` | Actor Confidence Tier — `ACT-1` (highest) through `ACT-4` | +| `vat` | Validated Actor Type — one of `HUMAN`, `AI_AGENT`, `SHARING`, `OTHER` | +| `act` | Actor Confidence Tier — one of `ACT-1`, `ACT-2`, `ACT-3` | ## GAM Targeting @@ -183,6 +185,6 @@ gulp serve-fast --modules=rtdModule,paywallsRtdProvider,appnexusBidAdapter ## Links -- [VAI Documentation](https://docs.paywalls.net/publishers/vai) +- [VAI Documentation](https://paywalls.net/docs.publishers/vai) - [Prebid RTD Module Documentation](https://docs.prebid.org/dev-docs/add-rtd-submodule.html) - [How Bid Adapters Should Read First Party Data](https://docs.prebid.org/features/firstPartyData.html#how-bid-adapters-should-read-first-party-data) diff --git a/plugins/eslint/approvedLoadExternalScriptPaths.js b/plugins/eslint/approvedLoadExternalScriptPaths.js index ba05dd03aa..df45af7086 100644 --- a/plugins/eslint/approvedLoadExternalScriptPaths.js +++ b/plugins/eslint/approvedLoadExternalScriptPaths.js @@ -34,6 +34,7 @@ const APPROVED_LOAD_EXTERNAL_SCRIPT_PATHS = [ 'modules/oftmediaRtdProvider.js', 'modules/panxoRtdProvider.js', 'modules/paywallsRtdProvider.js', + 'modules/paywallsAnalyticsAdapter.js', // UserId Submodules 'modules/justIdSystem.js', 'modules/tncIdSystem.js', diff --git a/test/spec/modules/paywallsAnalyticsAdapter_spec.js b/test/spec/modules/paywallsAnalyticsAdapter_spec.js new file mode 100644 index 0000000000..82110cec3f --- /dev/null +++ b/test/spec/modules/paywallsAnalyticsAdapter_spec.js @@ -0,0 +1,453 @@ +import paywallsAnalytics, { + getVaiClassification, + computeMetrics, + emitMetrics, + ensureVai, + adapterConfig, + resetForTesting, + DEFAULT_SCRIPT_URL, + VAI_WINDOW_KEY, +} from 'modules/paywallsAnalyticsAdapter.js'; +import {expect} from 'chai'; +import {expectEvents} from '../../helpers/analytics.js'; +import {EVENTS} from 'src/constants.js'; +import sinon from 'sinon'; + +const adapterManager = require('src/adapterManager').default; +const events = require('src/events'); + +describe('PaywallsAnalyticsAdapter', function () { + let sandbox; + let clock; + + before(function () { + sandbox = sinon.createSandbox(); + clock = sandbox.useFakeTimers(1896134400000); + }); + + after(function () { + clock.restore(); + sandbox.restore(); + }); + + // ----------------------------------------------------------------------- + // Registration & event tracking + // ----------------------------------------------------------------------- + + describe('registration', function () { + beforeEach(function () { + sandbox.stub(events, 'getEvents').returns([]); + resetForTesting(); + adapterManager.enableAnalytics({ + provider: 'paywalls', + options: {output: 'callback', callback: function () {}} + }); + }); + + afterEach(function () { + events.getEvents.restore(); + paywallsAnalytics.disableAnalytics(); + resetForTesting(); + delete window[VAI_WINDOW_KEY]; + }); + + it('should register with the analytics adapter manager', function () { + expect(paywallsAnalytics.enableAnalytics).to.be.a('function'); + }); + + it('should catch all events', function () { + sandbox.spy(paywallsAnalytics, 'track'); + expectEvents().to.beTrackedBy(paywallsAnalytics.track); + }); + }); + + // ----------------------------------------------------------------------- + // VAI loading + // ----------------------------------------------------------------------- + + describe('VAI loading', function () { + afterEach(function () { + delete window[VAI_WINDOW_KEY]; + resetForTesting(); + }); + + it('should detect VAI when window.__PW_VAI__ is present', function () { + window[VAI_WINDOW_KEY] = {vat: 'HUMAN', act: 'ACT-1'}; + const result = getVaiClassification(); + expect(result).to.deep.equal({vat: 'HUMAN', act: 'ACT-1'}); + }); + + it('should return null when window.__PW_VAI__ is absent', function () { + delete window[VAI_WINDOW_KEY]; + const result = getVaiClassification(); + expect(result).to.be.null; + }); + + it('should return null for invalid VAI (missing vat)', function () { + window[VAI_WINDOW_KEY] = {act: 'ACT-1'}; + expect(getVaiClassification()).to.be.null; + }); + + it('should return null for invalid VAI (missing act)', function () { + window[VAI_WINDOW_KEY] = {vat: 'HUMAN'}; + expect(getVaiClassification()).to.be.null; + }); + + it('should skip VAI injection when __PW_VAI__ is already present', function () { + window[VAI_WINDOW_KEY] = {vat: 'HUMAN', act: 'ACT-1'}; + sandbox.stub(events, 'getEvents').returns([]); + adapterManager.enableAnalytics({ + provider: 'paywalls', + options: {output: 'callback', callback: function () {}} + }); + expect(window[VAI_WINDOW_KEY].vat).to.equal('HUMAN'); + events.getEvents.restore(); + paywallsAnalytics.disableAnalytics(); + }); + }); + + // ----------------------------------------------------------------------- + // Metrics computation + // ----------------------------------------------------------------------- + + describe('computeMetrics', function () { + afterEach(function () { + delete window[VAI_WINDOW_KEY]; + }); + + it('should return vat and act when VAI is present', function () { + window[VAI_WINDOW_KEY] = {vat: 'HUMAN', act: 'ACT-1'}; + const result = computeMetrics(); + expect(result).to.deep.equal({vai_vat: 'HUMAN', vai_act: 'ACT-1'}); + }); + + it('should return UNKNOWN when VAI is absent', function () { + delete window[VAI_WINDOW_KEY]; + const result = computeMetrics(); + expect(result).to.deep.equal({vai_vat: 'UNKNOWN', vai_act: 'UNKNOWN'}); + }); + + it('should only contain vai_vat and vai_act keys', function () { + window[VAI_WINDOW_KEY] = {vat: 'AI_AGENT', act: 'ACT-2'}; + const result = computeMetrics(); + expect(Object.keys(result)).to.have.lengthOf(2); + expect(result).to.have.all.keys('vai_vat', 'vai_act'); + }); + }); + + // ----------------------------------------------------------------------- + // Output modes + // ----------------------------------------------------------------------- + + describe('output modes', function () { + afterEach(function () { + delete window[VAI_WINDOW_KEY]; + resetForTesting(); + }); + + describe('gtag', function () { + it('should call gtag with event and KVPs', function () { + const gtagStub = sandbox.stub(); + window.gtag = gtagStub; + + const kvps = {vai_vat: 'HUMAN', vai_act: 'ACT-1'}; + adapterConfig.output = 'gtag'; + emitMetrics(kvps); + + expect(gtagStub.calledOnce).to.be.true; + expect(gtagStub.firstCall.args[0]).to.equal('event'); + expect(gtagStub.firstCall.args[1]).to.equal('vai_auction'); + expect(gtagStub.firstCall.args[2]).to.deep.include({vai_vat: 'HUMAN'}); + + delete window.gtag; + }); + + it('should warn when gtag is not present', function () { + delete window.gtag; + adapterConfig.output = 'gtag'; + emitMetrics({vai_vat: 'HUMAN', vai_act: 'ACT-1'}); + }); + }); + + describe('dataLayer', function () { + it('should push to dataLayer with event name and KVPs', function () { + window.dataLayer = []; + adapterConfig.output = 'dataLayer'; + + const kvps = {vai_vat: 'HUMAN', vai_act: 'ACT-1'}; + emitMetrics(kvps); + + expect(window.dataLayer.length).to.equal(1); + expect(window.dataLayer[0].event).to.equal('vai_auction'); + expect(window.dataLayer[0].vai_vat).to.equal('HUMAN'); + expect(window.dataLayer[0].vai_act).to.equal('ACT-1'); + + delete window.dataLayer; + }); + + it('should create dataLayer if not present', function () { + delete window.dataLayer; + adapterConfig.output = 'dataLayer'; + + emitMetrics({vai_vat: 'HUMAN', vai_act: 'ACT-1'}); + + expect(window.dataLayer).to.be.an('array'); + expect(window.dataLayer.length).to.equal(1); + + delete window.dataLayer; + }); + }); + + describe('callback', function () { + it('should call the provided callback with KVPs', function () { + const cb = sandbox.stub(); + adapterConfig.output = 'callback'; + adapterConfig.callback = cb; + + const kvps = {vai_vat: 'HUMAN', vai_act: 'ACT-1'}; + emitMetrics(kvps); + + expect(cb.calledOnce).to.be.true; + expect(cb.firstCall.args[0]).to.deep.equal(kvps); + }); + + it('should handle callback errors gracefully', function () { + adapterConfig.output = 'callback'; + adapterConfig.callback = function () { throw new Error('test error'); }; + emitMetrics({vai_vat: 'HUMAN', vai_act: 'ACT-1'}); + }); + + it('should warn when callback is not a function', function () { + adapterConfig.output = 'callback'; + adapterConfig.callback = null; + emitMetrics({vai_vat: 'HUMAN', vai_act: 'ACT-1'}); + }); + }); + }); + + // ----------------------------------------------------------------------- + // End-to-end event flow + // ----------------------------------------------------------------------- + + describe('event correlation', function () { + let callbackSpy; + + beforeEach(function () { + callbackSpy = sandbox.stub(); + sandbox.stub(events, 'getEvents').returns([]); + resetForTesting(); + window[VAI_WINDOW_KEY] = {vat: 'HUMAN', act: 'ACT-1'}; + + adapterManager.enableAnalytics({ + provider: 'paywalls', + options: { + output: 'callback', + callback: callbackSpy, + scriptUrl: '/fake/vai.js', + } + }); + }); + + afterEach(function () { + events.getEvents.restore(); + paywallsAnalytics.disableAnalytics(); + resetForTesting(); + delete window[VAI_WINDOW_KEY]; + }); + + it('should emit vat and act on auctionEnd', function () { + events.emit(EVENTS.AUCTION_END, {auctionId: 'test-001'}); + clock.tick(200); + + expect(callbackSpy.calledOnce).to.be.true; + const kvps = callbackSpy.firstCall.args[0]; + expect(kvps.vai_vat).to.equal('HUMAN'); + expect(kvps.vai_act).to.equal('ACT-1'); + expect(Object.keys(kvps)).to.have.lengthOf(2); + }); + + it('should emit once per auction (de-dup)', function () { + events.emit(EVENTS.AUCTION_END, {auctionId: 'test-dedup'}); + clock.tick(200); + events.emit(EVENTS.AUCTION_END, {auctionId: 'test-dedup'}); + clock.tick(200); + + expect(callbackSpy.calledOnce).to.be.true; + }); + + it('should emit separately for different auctions', function () { + events.emit(EVENTS.AUCTION_END, {auctionId: 'auction-A'}); + clock.tick(200); + events.emit(EVENTS.AUCTION_END, {auctionId: 'auction-B'}); + clock.tick(200); + + expect(callbackSpy.calledTwice).to.be.true; + expect(callbackSpy.firstCall.args[0].vai_vat).to.equal('HUMAN'); + expect(callbackSpy.secondCall.args[0].vai_vat).to.equal('HUMAN'); + }); + + it('should ignore events without auctionId', function () { + events.emit(EVENTS.AUCTION_END, {}); + clock.tick(200); + + expect(callbackSpy.called).to.be.false; + }); + + it('should ignore non-AUCTION_END events', function () { + events.emit(EVENTS.BID_RESPONSE, {auctionId: 'test-001', cpm: 2.50}); + events.emit(EVENTS.BID_WON, {auctionId: 'test-001'}); + events.emit(EVENTS.NO_BID, {auctionId: 'test-001'}); + clock.tick(200); + + expect(callbackSpy.called).to.be.false; + }); + }); + + // ----------------------------------------------------------------------- + // Sampling + // ----------------------------------------------------------------------- + + describe('sampling', function () { + afterEach(function () { + delete window[VAI_WINDOW_KEY]; + resetForTesting(); + }); + + it('should skip emission when sampled out', function () { + const cb = sandbox.stub(); + sandbox.stub(events, 'getEvents').returns([]); + window[VAI_WINDOW_KEY] = {vat: 'HUMAN', act: 'ACT-1'}; + + sandbox.stub(Math, 'random').returns(0.5); + adapterManager.enableAnalytics({ + provider: 'paywalls', + options: { + output: 'callback', + callback: cb, + samplingRate: 0.1 + } + }); + + events.emit(EVENTS.AUCTION_END, {auctionId: 'sampled-out'}); + clock.tick(200); + + expect(cb.called).to.be.false; + + Math.random.restore(); + events.getEvents.restore(); + paywallsAnalytics.disableAnalytics(); + }); + + it('should emit when sampled in', function () { + const cb = sandbox.stub(); + sandbox.stub(events, 'getEvents').returns([]); + window[VAI_WINDOW_KEY] = {vat: 'HUMAN', act: 'ACT-1'}; + + sandbox.stub(Math, 'random').returns(0.01); + adapterManager.enableAnalytics({ + provider: 'paywalls', + options: { + output: 'callback', + callback: cb, + samplingRate: 0.5 + } + }); + + events.emit(EVENTS.AUCTION_END, {auctionId: 'sampled-in'}); + clock.tick(200); + + expect(cb.calledOnce).to.be.true; + + Math.random.restore(); + events.getEvents.restore(); + paywallsAnalytics.disableAnalytics(); + }); + }); + + // ----------------------------------------------------------------------- + // Graceful degradation + // ----------------------------------------------------------------------- + + describe('graceful degradation', function () { + it('should emit UNKNOWN when VAI is unavailable', function () { + const cb = sandbox.stub(); + sandbox.stub(events, 'getEvents').returns([]); + resetForTesting(); + delete window[VAI_WINDOW_KEY]; + + adapterManager.enableAnalytics({ + provider: 'paywalls', + options: { + output: 'callback', + callback: cb, + scriptUrl: '/nonexistent-vai.js' + } + }); + + events.emit(EVENTS.AUCTION_END, {auctionId: 'degraded'}); + clock.tick(200); + + expect(cb.calledOnce).to.be.true; + const kvps = cb.firstCall.args[0]; + expect(kvps.vai_vat).to.equal('UNKNOWN'); + expect(kvps.vai_act).to.equal('UNKNOWN'); + + events.getEvents.restore(); + paywallsAnalytics.disableAnalytics(); + }); + }); + + // ----------------------------------------------------------------------- + // Config parsing + // ----------------------------------------------------------------------- + + describe('configuration', function () { + beforeEach(function () { + sandbox.stub(events, 'getEvents').returns([]); + resetForTesting(); + }); + + afterEach(function () { + events.getEvents.restore(); + paywallsAnalytics.disableAnalytics(); + resetForTesting(); + delete window[VAI_WINDOW_KEY]; + }); + + it('should use default scriptUrl when not provided', function () { + adapterManager.enableAnalytics({ + provider: 'paywalls', + options: {output: 'callback', callback: function () {}} + }); + expect(adapterConfig.scriptUrl).to.equal(DEFAULT_SCRIPT_URL); + }); + + it('should use custom scriptUrl when provided', function () { + adapterManager.enableAnalytics({ + provider: 'paywalls', + options: { + output: 'callback', + callback: function () {}, + scriptUrl: 'https://cdn.example.com/vai.js' + } + }); + expect(adapterConfig.scriptUrl).to.equal('https://cdn.example.com/vai.js'); + }); + + it('should default samplingRate to 1.0', function () { + adapterManager.enableAnalytics({ + provider: 'paywalls', + options: {output: 'callback', callback: function () {}} + }); + expect(adapterConfig.samplingRate).to.equal(1.0); + }); + + it('should default output to callback', function () { + adapterManager.enableAnalytics({ + provider: 'paywalls', + options: {callback: function () {}} + }); + expect(adapterConfig.output).to.equal('callback'); + }); + }); +}); From d6a857316af753d3eb36b9881dfd33367931cc61 Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Sun, 1 Mar 2026 11:46:32 -0800 Subject: [PATCH 07/22] Paywalls Modules: fix doc links and improve RTD provider docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix broken VAI URL in RTD provider links section (docs.publishers → docs/publishers) - Normalize all VAI URLs to https://docs.paywalls.net/publishers/vai - Align RTD provider heading structure with analytics adapter (# Overview) - Clarify params.waitForIt vs top-level waitForIt distinction - Document __PW_VAI_HOOK__, exp checking, and localStorage key in How It Works --- modules/paywallsAnalyticsAdapter.md | 2 +- modules/paywallsRtdProvider.md | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/modules/paywallsAnalyticsAdapter.md b/modules/paywallsAnalyticsAdapter.md index a9a7927b4c..c619c1e681 100644 --- a/modules/paywallsAnalyticsAdapter.md +++ b/modules/paywallsAnalyticsAdapter.md @@ -168,6 +168,6 @@ gulp serve-fast --modules=rtdModule,paywallsRtdProvider,paywallsAnalyticsAdapter ## Links -- [VAI Documentation](https://paywalls.net/docs/publishers/vai) +- [VAI Documentation](https://docs.paywalls.net/publishers/vai) - [Paywalls RTD Provider](paywallsRtdProvider.md) - [Prebid Analytics Adapter Documentation](https://docs.prebid.org/dev-docs/integrate-with-the-prebid-analytics-api.html) diff --git a/modules/paywallsRtdProvider.md b/modules/paywallsRtdProvider.md index f4649f4b21..ba9fe1a568 100644 --- a/modules/paywallsRtdProvider.md +++ b/modules/paywallsRtdProvider.md @@ -1,6 +1,4 @@ -# Paywalls RTD Provider - -## Overview +# Overview **Module Name:** Paywalls RTD Provider @@ -10,7 +8,7 @@ ## Description -The Paywalls RTD module integrates [VAI (Validated Actor Inventory)](https://paywalls.net/docs/publishers/vai) into Prebid.js. VAI classifies page impressions by **actor type** (`vat`) and **confidence tier** (`act`), producing a cryptographically signed assertion that SSPs can independently verify. +The Paywalls RTD module integrates [VAI (Validated Actor Inventory)](https://docs.paywalls.net/publishers/vai) into Prebid.js. VAI classifies page impressions by **actor type** (`vat`) and **confidence tier** (`act`), producing a cryptographically signed assertion that SSPs can independently verify. The module automates VAI loading, timing, and signal injection: @@ -55,7 +53,7 @@ pbjs.setConfig({ | `waitForIt` | `Boolean` | Optional | Should be `true` when `auctionDelay` is set | `false` | | `params` | `Object` | Optional | Provider configuration | `{}` | | `params.scriptUrl` | `String` | Optional | URL of the VAI loader script | `'/pw/vai.js'` | -| `params.waitForIt` | `Number` | Optional | Max ms to wait for VAI before releasing the auction | `100` | +| `params.waitForIt` | `Number` | Optional | Max ms to wait for VAI before releasing the auction (distinct from the Boolean `waitForIt` above) | `100` | ### Hosting Modes @@ -162,8 +160,8 @@ pbjs.setConfig({ ## How It Works -1. **`init()`** — Checks `window.__PW_VAI__` and `localStorage` for an existing VAI payload. If present and unexpired, caches it for immediate use. -2. **`getBidRequestData()`** — If cached VAI exists, merges ORTB2 and calls back immediately (fast path). Otherwise, injects `vai.js` via `loadExternalScript` and polls/hooks for the result until `waitForIt` ms elapse (slow path). On timeout, calls back without enrichment. +1. **`init()`** — Checks `window.__PW_VAI__` and `localStorage` (key: `__pw_vai__`) for an existing VAI payload. If present and unexpired (`exp` > now), caches it for immediate use. +2. **`getBidRequestData()`** — If cached VAI exists, merges ORTB2 and calls back immediately (fast path). Otherwise, injects `vai.js` via `loadExternalScript`, sets up `window.__PW_VAI_HOOK__` as a callback for the script to deliver the payload, and polls `window.__PW_VAI__` until `waitForIt` ms elapse (slow path). On timeout, calls back without enrichment. 3. **`getTargetingData()`** — Returns `{ vai_vat, vai_act }` for each ad unit from the current VAI payload. ## Testing @@ -185,6 +183,6 @@ gulp serve-fast --modules=rtdModule,paywallsRtdProvider,appnexusBidAdapter ## Links -- [VAI Documentation](https://paywalls.net/docs.publishers/vai) +- [VAI Documentation](https://docs.paywalls.net/publishers/vai) - [Prebid RTD Module Documentation](https://docs.prebid.org/dev-docs/add-rtd-submodule.html) - [How Bid Adapters Should Read First Party Data](https://docs.prebid.org/features/firstPartyData.html#how-bid-adapters-should-read-first-party-data) From c2e15aea5df16765d71233cce3900ba447ab1fda Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Sun, 1 Mar 2026 11:58:05 -0800 Subject: [PATCH 08/22] Paywalls Modules: fix VAI URL to paywalls.net/docs (not docs.paywalls.net) --- modules/paywallsAnalyticsAdapter.js | 2 +- modules/paywallsAnalyticsAdapter.md | 4 ++-- modules/paywallsRtdProvider.js | 2 +- modules/paywallsRtdProvider.md | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/paywallsAnalyticsAdapter.js b/modules/paywallsAnalyticsAdapter.js index 91a26c7503..eab68eea55 100644 --- a/modules/paywallsAnalyticsAdapter.js +++ b/modules/paywallsAnalyticsAdapter.js @@ -10,7 +10,7 @@ * on it natively. * * @module modules/paywallsAnalyticsAdapter - * @see https://docs.paywalls.net/publishers/vai + * @see https://paywalls.net/docs/publishers/vai */ import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; diff --git a/modules/paywallsAnalyticsAdapter.md b/modules/paywallsAnalyticsAdapter.md index c619c1e681..84ff5acf2d 100644 --- a/modules/paywallsAnalyticsAdapter.md +++ b/modules/paywallsAnalyticsAdapter.md @@ -9,7 +9,7 @@ ## Description -The Paywalls Analytics Adapter emits [VAI (Validated Actor Inventory)](https://docs.paywalls.net/publishers/vai) classification on each Prebid auction. VAI helps publishers distinguish **human traffic** and **AI agents** from **non-human automation** (sharing/preview bots, search crawlers, AI training, etc.) so they can make better-informed economic decisions. +The Paywalls Analytics Adapter emits [VAI (Validated Actor Inventory)](https://paywalls.net/docs/publishers/vai) classification on each Prebid auction. VAI helps publishers distinguish **human traffic** and **AI agents** from **non-human automation** (sharing/preview bots, search crawlers, AI training, etc.) so they can make better-informed economic decisions. In practice, this enables publishers to: @@ -168,6 +168,6 @@ gulp serve-fast --modules=rtdModule,paywallsRtdProvider,paywallsAnalyticsAdapter ## Links -- [VAI Documentation](https://docs.paywalls.net/publishers/vai) +- [VAI Documentation](https://paywalls.net/docs/publishers/vai) - [Paywalls RTD Provider](paywallsRtdProvider.md) - [Prebid Analytics Adapter Documentation](https://docs.prebid.org/dev-docs/integrate-with-the-prebid-analytics-api.html) diff --git a/modules/paywallsRtdProvider.js b/modules/paywallsRtdProvider.js index f09c19ee8e..3780fd00fc 100644 --- a/modules/paywallsRtdProvider.js +++ b/modules/paywallsRtdProvider.js @@ -12,7 +12,7 @@ * * @module modules/paywallsRtdProvider * @requires module:modules/realTimeData - * @see https://docs.paywalls.net/publishers/vai + * @see https://paywalls.net/docs/publishers/vai */ import {submodule} from '../src/hook.js'; diff --git a/modules/paywallsRtdProvider.md b/modules/paywallsRtdProvider.md index ba9fe1a568..c1ba2cbb0a 100644 --- a/modules/paywallsRtdProvider.md +++ b/modules/paywallsRtdProvider.md @@ -8,7 +8,7 @@ ## Description -The Paywalls RTD module integrates [VAI (Validated Actor Inventory)](https://docs.paywalls.net/publishers/vai) into Prebid.js. VAI classifies page impressions by **actor type** (`vat`) and **confidence tier** (`act`), producing a cryptographically signed assertion that SSPs can independently verify. +The Paywalls RTD module integrates [VAI (Validated Actor Inventory)](https://paywalls.net/docs/publishers/vai) into Prebid.js. VAI classifies page impressions by **actor type** (`vat`) and **confidence tier** (`act`), producing a cryptographically signed assertion that SSPs can independently verify. The module automates VAI loading, timing, and signal injection: @@ -183,6 +183,6 @@ gulp serve-fast --modules=rtdModule,paywallsRtdProvider,appnexusBidAdapter ## Links -- [VAI Documentation](https://docs.paywalls.net/publishers/vai) +- [VAI Documentation](https://paywalls.net/docs/publishers/vai) - [Prebid RTD Module Documentation](https://docs.prebid.org/dev-docs/add-rtd-submodule.html) - [How Bid Adapters Should Read First Party Data](https://docs.prebid.org/features/firstPartyData.html#how-bid-adapters-should-read-first-party-data) From d145be854c436bfe092c5ae77659e2b371288492 Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Sun, 1 Mar 2026 13:41:34 -0800 Subject: [PATCH 09/22] Paywalls Modules: fix bugs from automated review feedback - P1: Store late VAI hook payloads for subsequent auctions (was dropping permanently after timeout) - Fix waitForIt=0 treated as falsy (use typeof check instead of ||) - Clean up timers and hook in resolve() to avoid leaking globals - Preserve existing __PW_VAI_HOOK__ instead of clobbering - Fix ensureVai skipping injection when __PW_VAI__ is truthy but invalid - Clamp samplingRate to [0,1] with warning - Use Object.create(null) for emittedAuctions map - Fix init() JSDoc to match actual behavior (caches, not injects) - Add regression tests for all bug fixes --- modules/paywallsAnalyticsAdapter.js | 26 ++-- modules/paywallsRtdProvider.js | 88 ++++++++++--- .../modules/paywallsAnalyticsAdapter_spec.js | 69 ++++++++++ test/spec/modules/paywallsRtdProvider_spec.js | 121 ++++++++++++++++++ 4 files changed, 280 insertions(+), 24 deletions(-) diff --git a/modules/paywallsAnalyticsAdapter.js b/modules/paywallsAnalyticsAdapter.js index eab68eea55..09c1b6fd80 100644 --- a/modules/paywallsAnalyticsAdapter.js +++ b/modules/paywallsAnalyticsAdapter.js @@ -29,7 +29,7 @@ export const VAI_WINDOW_KEY = '__PW_VAI__'; const {AUCTION_END} = EVENTS; // Track which auctions we've already emitted for (de-dup) -let emittedAuctions = {}; +let emittedAuctions = Object.create(null); // --------------------------------------------------------------------------- // Config (set during enableAnalytics) @@ -59,6 +59,9 @@ let vaiInjected = false; export function getVaiClassification() { const vai = window[VAI_WINDOW_KEY]; if (vai && typeof vai === 'object' && vai.vat && vai.act) { + if (typeof vai.exp === 'number' && vai.exp < Math.floor(Date.now() / 1000)) { + return null; + } return {vat: vai.vat, act: vai.act}; } return null; @@ -70,8 +73,9 @@ export function getVaiClassification() { * @param {string} scriptUrl */ export function ensureVai(scriptUrl) { - if (window[VAI_WINDOW_KEY]) { - logInfo(LOG_PREFIX + 'VAI already present. vat=' + window[VAI_WINDOW_KEY].vat); + const existing = getVaiClassification(); + if (existing) { + logInfo(LOG_PREFIX + 'VAI already present. vat=' + existing.vat); return; } if (vaiInjected) { @@ -197,7 +201,11 @@ paywallsAnalytics.enableAnalytics = function (config) { // Parse config adapterConfig.output = options.output || 'callback'; adapterConfig.scriptUrl = options.scriptUrl || DEFAULT_SCRIPT_URL; - adapterConfig.samplingRate = typeof options.samplingRate === 'number' ? options.samplingRate : 1.0; + const rawRate = typeof options.samplingRate === 'number' ? options.samplingRate : 1.0; + adapterConfig.samplingRate = Math.max(0, Math.min(1, rawRate)); + if (rawRate !== adapterConfig.samplingRate) { + logWarn(LOG_PREFIX + 'samplingRate clamped to [0, 1]: ' + rawRate + ' → ' + adapterConfig.samplingRate); + } adapterConfig.callback = typeof options.callback === 'function' ? options.callback : null; // Sampling decision @@ -207,11 +215,13 @@ paywallsAnalytics.enableAnalytics = function (config) { } // Reset state - emittedAuctions = {}; + emittedAuctions = Object.create(null); vaiInjected = false; - // Ensure VAI is on the page - ensureVai(adapterConfig.scriptUrl); + // Ensure VAI is on the page only when this session is sampled in + if (sampledIn) { + ensureVai(adapterConfig.scriptUrl); + } // Call original enable (wires up event listeners) paywallsAnalytics.originEnableAnalytics(config); @@ -228,7 +238,7 @@ adapterManager.registerAnalyticsAdapter({ * @param {boolean} [overrides.sampledIn] */ export function resetForTesting(overrides) { - emittedAuctions = {}; + emittedAuctions = Object.create(null); vaiInjected = false; sampledIn = true; adapterConfig.output = 'callback'; diff --git a/modules/paywallsRtdProvider.js b/modules/paywallsRtdProvider.js index 3780fd00fc..987b7c0c15 100644 --- a/modules/paywallsRtdProvider.js +++ b/modules/paywallsRtdProvider.js @@ -35,6 +35,7 @@ export const VAI_HOOK_KEY = '__PW_VAI_HOOK__'; export const VAI_LS_KEY = '__pw_vai__'; const DEFAULT_WAIT_FOR_IT = 100; +const LATE_HOOK_GRACE_MS = 1000; // Cached VAI payload from init (for early detection) let cachedVai = null; @@ -151,8 +152,8 @@ function mergeOrtb2Fragments(reqBidsConfigObj, vai) { * init — called once when the submodule is registered. * * Checks for an existing VAI payload (window global or localStorage). - * If valid+unexpired, injects ORTB2 immediately so bid requests that - * fire before getBidRequestData still carry VAI signals. + * If valid+unexpired, caches it so getBidRequestData can inject ORTB2 + * immediately on the first call without script injection. * * @param {object} rtdConfig Provider configuration from realTimeData.dataProviders * @param {object} userConsent Consent data @@ -194,41 +195,94 @@ function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { // Slow path: need to inject vai.js and wait const params = (rtdConfig && rtdConfig.params) || {}; const scriptUrl = params.scriptUrl || DEFAULT_SCRIPT_URL; - const waitForIt = params.waitForIt || DEFAULT_WAIT_FOR_IT; + const rawWaitForIt = params.waitForIt; + const waitForIt = (typeof rawWaitForIt === 'number' && rawWaitForIt >= 0) + ? rawWaitForIt + : DEFAULT_WAIT_FOR_IT; + if (rawWaitForIt != null && (typeof rawWaitForIt !== 'number' || rawWaitForIt < 0)) { + logWarn(LOG_PREFIX + 'Invalid waitForIt value (' + rawWaitForIt + '); using default ' + DEFAULT_WAIT_FOR_IT); + } let resolved = false; + let enriched = false; + let pollId = null; + let timeoutId = null; + let lateHookCleanupId = null; + + const previousHook = (typeof window[VAI_HOOK_KEY] === 'function') ? window[VAI_HOOK_KEY] : null; + + function restoreHook() { + if (window[VAI_HOOK_KEY] === hookHandler) { + if (previousHook) { + window[VAI_HOOK_KEY] = previousHook; + } else { + delete window[VAI_HOOK_KEY]; + } + } + if (lateHookCleanupId != null) { + clearTimeout(lateHookCleanupId); + lateHookCleanupId = null; + } + } + + function cleanup() { + if (pollId != null) { clearInterval(pollId); pollId = null; } + if (timeoutId != null) { clearTimeout(timeoutId); timeoutId = null; } + } + + function installLateHookCapture() { + lateHookCleanupId = setTimeout(function () { + restoreHook(); + }, Math.max(waitForIt, LATE_HOOK_GRACE_MS)); + } function resolve(vai) { - if (resolved) return; + const validVai = !!(vai && isValid(vai)); + if (resolved) { + // Even after timeout, store late payloads so subsequent auctions can use them + if (!enriched && validVai) { + window[VAI_WINDOW_KEY] = vai; + enriched = true; + restoreHook(); + logInfo(LOG_PREFIX + 'late VAI payload stored for subsequent auctions. vat=' + vai.vat); + } + return; + } resolved = true; - if (vai && isValid(vai)) { - // Also set the window global so subsequent calls are fast + enriched = validVai; + cleanup(); + if (validVai) { window[VAI_WINDOW_KEY] = vai; + restoreHook(); mergeOrtb2Fragments(reqBidsConfigObj, vai); } else { + installLateHookCapture(); logWarn(LOG_PREFIX + 'VAI unavailable — proceeding without enrichment'); } callback(); } // Set up the hook that vai.js may call - window[VAI_HOOK_KEY] = function (vaiData) { + // Preserve any existing hook set by the page + function hookHandler(vaiData) { resolve(vaiData); - }; + if (previousHook) { + try { previousHook(vaiData); } catch (e) { logWarn(LOG_PREFIX + 'Error in existing VAI hook:', e); } + } + } + window[VAI_HOOK_KEY] = hookHandler; // Set up a poll interval to check for window.__PW_VAI__ const pollInterval = 10; // ms - const pollId = setInterval(function () { + pollId = setInterval(function () { const vai = window[VAI_WINDOW_KEY]; if (vai && isValid(vai)) { - clearInterval(pollId); resolve(vai); } }, pollInterval); // Set up timeout (graceful degradation — never block the auction) - const timeoutId = setTimeout(function () { - clearInterval(pollId); + timeoutId = setTimeout(function () { resolve(null); }, waitForIt); @@ -240,15 +294,17 @@ function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { // (script may have set it synchronously) const vai = window[VAI_WINDOW_KEY]; if (vai && isValid(vai)) { - clearInterval(pollId); - clearTimeout(timeoutId); resolve(vai); + } else if (resolved && !enriched) { + // Script loaded after timeout but hasn't delivered VAI yet. + // Extend hook grace so async delivery (e.g. fetch of vai.json) can still reach us. + if (lateHookCleanupId != null) { clearTimeout(lateHookCleanupId); } + lateHookCleanupId = setTimeout(function () { restoreHook(); }, LATE_HOOK_GRACE_MS); + logInfo(LOG_PREFIX + 'script loaded post-timeout — extending hook grace by ' + LATE_HOOK_GRACE_MS + 'ms'); } }); } catch (e) { logWarn(LOG_PREFIX + 'loadExternalScript failed:', e); - clearInterval(pollId); - clearTimeout(timeoutId); resolve(null); } } diff --git a/test/spec/modules/paywallsAnalyticsAdapter_spec.js b/test/spec/modules/paywallsAnalyticsAdapter_spec.js index 82110cec3f..eca07a8bc2 100644 --- a/test/spec/modules/paywallsAnalyticsAdapter_spec.js +++ b/test/spec/modules/paywallsAnalyticsAdapter_spec.js @@ -12,6 +12,7 @@ import {expect} from 'chai'; import {expectEvents} from '../../helpers/analytics.js'; import {EVENTS} from 'src/constants.js'; import sinon from 'sinon'; +import { loadExternalScriptStub } from 'test/mocks/adloaderStub.js'; const adapterManager = require('src/adapterManager').default; const events = require('src/events'); @@ -93,6 +94,12 @@ describe('PaywallsAnalyticsAdapter', function () { expect(getVaiClassification()).to.be.null; }); + it('should return null for expired VAI', function () { + const nowSec = Math.floor(Date.now() / 1000); + window[VAI_WINDOW_KEY] = {vat: 'HUMAN', act: 'ACT-1', exp: nowSec - 1}; + expect(getVaiClassification()).to.be.null; + }); + it('should skip VAI injection when __PW_VAI__ is already present', function () { window[VAI_WINDOW_KEY] = {vat: 'HUMAN', act: 'ACT-1'}; sandbox.stub(events, 'getEvents').returns([]); @@ -127,6 +134,13 @@ describe('PaywallsAnalyticsAdapter', function () { expect(result).to.deep.equal({vai_vat: 'UNKNOWN', vai_act: 'UNKNOWN'}); }); + it('should return UNKNOWN when VAI is expired', function () { + const nowSec = Math.floor(Date.now() / 1000); + window[VAI_WINDOW_KEY] = {vat: 'HUMAN', act: 'ACT-1', exp: nowSec - 1}; + const result = computeMetrics(); + expect(result).to.deep.equal({vai_vat: 'UNKNOWN', vai_act: 'UNKNOWN'}); + }); + it('should only contain vai_vat and vai_act keys', function () { window[VAI_WINDOW_KEY] = {vat: 'AI_AGENT', act: 'ACT-2'}; const result = computeMetrics(); @@ -338,6 +352,30 @@ describe('PaywallsAnalyticsAdapter', function () { paywallsAnalytics.disableAnalytics(); }); + it('should not load vai.js when sampled out', function () { + const cb = sandbox.stub(); + sandbox.stub(events, 'getEvents').returns([]); + delete window[VAI_WINDOW_KEY]; + loadExternalScriptStub.resetHistory(); + + sandbox.stub(Math, 'random').returns(0.9); + adapterManager.enableAnalytics({ + provider: 'paywalls', + options: { + output: 'callback', + callback: cb, + samplingRate: 0.1, + scriptUrl: '/pw/vai.js' + } + }); + + expect(loadExternalScriptStub.called).to.be.false; + + Math.random.restore(); + events.getEvents.restore(); + paywallsAnalytics.disableAnalytics(); + }); + it('should emit when sampled in', function () { const cb = sandbox.stub(); sandbox.stub(events, 'getEvents').returns([]); @@ -364,6 +402,37 @@ describe('PaywallsAnalyticsAdapter', function () { }); }); + // ----------------------------------------------------------------------- + // Bug reproductions (from bot review feedback) + // ----------------------------------------------------------------------- + + describe('bug reproductions', function () { + afterEach(function () { + delete window[VAI_WINDOW_KEY]; + resetForTesting(); + }); + + it('ensureVai should inject script when __PW_VAI__ is truthy but invalid', function () { + // Bug: ensureVai() checks `if (window[VAI_WINDOW_KEY])` which is truthy + // for objects missing vat/act, preventing injection + window[VAI_WINDOW_KEY] = { invalid: true }; + resetForTesting(); + loadExternalScriptStub.resetHistory(); + + // Call ensureVai directly — it should detect the invalid payload and inject + ensureVai('/pw/vai.js'); + + // Proves the object is invalid for classification + const classification = getVaiClassification(); + expect(classification).to.be.null; + + // With the bug, loadExternalScript would NOT be called because + // ensureVai saw a truthy window.__PW_VAI__ and returned early. + // After fix, it should call loadExternalScript. + expect(loadExternalScriptStub.called).to.be.true; + }); + }); + // ----------------------------------------------------------------------- // Graceful degradation // ----------------------------------------------------------------------- diff --git a/test/spec/modules/paywallsRtdProvider_spec.js b/test/spec/modules/paywallsRtdProvider_spec.js index fc4d017900..b311fc2e72 100644 --- a/test/spec/modules/paywallsRtdProvider_spec.js +++ b/test/spec/modules/paywallsRtdProvider_spec.js @@ -320,6 +320,127 @@ describe('paywallsRtdProvider', function () { }); }); + // ------------------------------------------------------------------------- + // Bug reproductions (from bot review feedback) + // ------------------------------------------------------------------------- + + describe('bug reproductions', function () { + let clock; + + beforeEach(function () { + clock = sinon.useFakeTimers(); + }); + + afterEach(function () { + clock.restore(); + }); + + it('P1: late hook payload should be stored for subsequent auctions', function (done) { + // Bug: if timeout fires before hook, the payload is dropped permanently. + // Subsequent auctions keep degrading because loadExternalScript won't re-execute. + const reqBids1 = makeReqBids(); + const fastConfig = { name: SUBMODULE_NAME, params: { waitForIt: 10 } }; + + loadExternalScriptStub.callsFake(() => { + // Simulate hook-only delivery AFTER timeout (no window global fallback) + setTimeout(() => { + if (typeof window[VAI_HOOK_KEY] === 'function') { + window[VAI_HOOK_KEY]({ ...MOCK_VAI }); + } + }, 20); + }); + + // First auction: should timeout and degrade + paywallsSubmodule.init(fastConfig, {}); + paywallsSubmodule.getBidRequestData(reqBids1, function () { + // First auction degraded — expected + const global1 = reqBids1.ortb2Fragments.global; + expect(global1).to.not.have.nested.property('site.ext.vai'); + + // Now hook delivers late payload (at t=20ms) + clock.tick(15); + + // Second auction should pick up the late payload + const reqBids2 = makeReqBids(); + paywallsSubmodule.getBidRequestData(reqBids2, function () { + const global2 = reqBids2.ortb2Fragments.global; + expect(global2.user.ext.vai.vat).to.equal('HUMAN'); + expect(global2.user.ext.vai.act).to.equal('ACT-1'); + done(); + }, fastConfig, {}); + }, fastConfig, {}); + + // Advance past timeout + clock.tick(15); + }); + + it('waitForIt=0 should be respected, not treated as falsy', function (done) { + // Bug: params.waitForIt || DEFAULT_WAIT_FOR_IT treats 0 as falsy + const reqBids = makeReqBids(); + const zeroConfig = { name: SUBMODULE_NAME, params: { waitForIt: 0 } }; + let callbackTime = null; + + loadExternalScriptStub.callsFake(() => { + // Script never sets __PW_VAI__ — should timeout at 0ms, not 100ms + }); + + paywallsSubmodule.init(zeroConfig, {}); + paywallsSubmodule.getBidRequestData(reqBids, function () { + callbackTime = clock.now; + done(); + }, zeroConfig, {}); + + // If bug exists, callback won't fire until 100ms (DEFAULT_WAIT_FOR_IT) + // With fix, it should fire at ~0ms + clock.tick(1); + // If we get here without done() being called, the test will timeout + }); + + it('should extend hook grace when script loads after timeout', function (done) { + // Scenario: slow network — script loads well after waitForIt timeout. + // Hook delivery happens 50ms after script load, which is beyond the + // initial grace window. The onload extension should keep the hook alive. + const reqBids1 = makeReqBids(); + const fastConfig = { name: SUBMODULE_NAME, params: { waitForIt: 10 } }; + let scriptOnload; + + loadExternalScriptStub.callsFake((_url, _type, _name, onload) => { + // Capture the onload so we can fire it manually later + scriptOnload = onload; + }); + + paywallsSubmodule.init(fastConfig, {}); + paywallsSubmodule.getBidRequestData(reqBids1, function () { + // First auction degrades (expected) + + // Simulate script loading 500ms after timeout (well past initial grace) + clock.tick(500); + scriptOnload(); // fires onload — should extend hook grace + + // Hook delivers VAI 50ms after script load + clock.tick(50); + if (typeof window[VAI_HOOK_KEY] === 'function') { + window[VAI_HOOK_KEY]({ ...MOCK_VAI }); + } + + // Verify the late payload was stored + expect(window[VAI_WINDOW_KEY]).to.have.property('vat', 'HUMAN'); + + // Second auction should use the stored payload + const reqBids2 = makeReqBids(); + paywallsSubmodule.getBidRequestData(reqBids2, function () { + const global2 = reqBids2.ortb2Fragments.global; + expect(global2.user.ext.vai.vat).to.equal('HUMAN'); + expect(global2.user.ext.vai.act).to.equal('ACT-1'); + done(); + }, fastConfig, {}); + }, fastConfig, {}); + + // Advance past timeout + clock.tick(15); + }); + }); + // ------------------------------------------------------------------------- // getTargetingData // ------------------------------------------------------------------------- From b0763949ee7e3e80c122553a035372024324942e Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Tue, 3 Mar 2026 08:47:36 -0800 Subject: [PATCH 10/22] Paywalls RTD Provider: VAI refactor - field renames, 3-scope ORTB2, pvtk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildOrtb2 now uses 3-scope structure: site.ext.vai: { iss, dom } user.ext.vai: { iss, vat, act, mstk, jws } imp[].ext.vai: { pvtk } (when available) - Removed aud, kid, assertion_jws from ORTB2 injection - iss uses bare domain (paywalls.net) - mergeOrtb2Fragments enriches adUnit.ortb2Imp with pvtk - paywallsRtdProvider.md: rewritten ORTB2 Output section (3 scopes) - paywallsAnalyticsAdapter.md: updated scope refs, assertion_jws→jws - Tests: updated MOCK_VAI fixture, buildOrtb2 assertions, added pvtk tests - 27 RTD tests pass, 33 analytics tests pass, lint clean --- modules/paywallsAnalyticsAdapter.md | 4 +- modules/paywallsRtdProvider.js | 34 +++++++--- modules/paywallsRtdProvider.md | 64 ++++++++++++------- test/spec/modules/paywallsRtdProvider_spec.js | 57 ++++++++++++++--- 4 files changed, 118 insertions(+), 41 deletions(-) diff --git a/modules/paywallsAnalyticsAdapter.md b/modules/paywallsAnalyticsAdapter.md index 84ff5acf2d..c3294c5464 100644 --- a/modules/paywallsAnalyticsAdapter.md +++ b/modules/paywallsAnalyticsAdapter.md @@ -27,7 +27,7 @@ Two key-value pairs are emitted per auction: If VAI is unavailable (script failed to load, timed out, or returned an invalid response), both values are `UNKNOWN`. -> **No bid-level aggregation is performed.** The companion [Paywalls RTD Provider](paywallsRtdProvider.md) injects VAI into ORTB2 (`site.ext.vai`, `user.ext.vai`) and GAM targeting (`vai_vat`, `vai_act`), so SSPs, DSPs, GAM, and warehouse pipelines already have the signals needed to aggregate natively. The analytics adapter simply confirms classification is reaching the page and routes it to the publisher's analytics tool of choice. +> **No bid-level aggregation is performed.** The companion [Paywalls RTD Provider](paywallsRtdProvider.md) injects VAI into ORTB2 (`site.ext.vai`, `user.ext.vai`, `imp[].ext.vai`) and GAM targeting (`vai_vat`, `vai_act`), so SSPs, DSPs, GAM, and warehouse pipelines already have the signals needed to aggregate natively. The analytics adapter simply confirms classification is reaching the page and routes it to the publisher's analytics tool of choice. ## Build Instructions @@ -147,7 +147,7 @@ pbjs.setConfig({ - **No user identifiers**: VAI does not collect, store, or transmit user IDs, cookies, or fingerprints. - **No PII**: The classification is based on aggregate session-level behavioral signals, not personal data. - **Browser-side only**: All signal extraction runs in the browser; no data leaves the page except the classification result. -- **Signed assertions**: SSPs can independently verify the `assertion_jws` via the JWKS endpoint pulled from the JWS header (typically `https://example.com/pw/jwks.json`), ensuring the classification has not been tampered with. +- **Signed assertions**: SSPs can independently verify the `jws` via the JWKS endpoint pulled from the JWS header (typically `https://example.com/pw/jwks.json`), ensuring the classification has not been tampered with. ## Testing diff --git a/modules/paywallsRtdProvider.js b/modules/paywallsRtdProvider.js index 987b7c0c15..325a90cd3c 100644 --- a/modules/paywallsRtdProvider.js +++ b/modules/paywallsRtdProvider.js @@ -7,8 +7,9 @@ * automates VAI loading, timing, and ORTB2 injection. * * ORTB2 placement (canonical split): - * site.ext.vai — { iss, aud, dom, kid, assertion_jws } - * user.ext.vai — { vat, act } + * site.ext.vai — { iss, dom } + * user.ext.vai — { iss, mstk, vat, act, jws } + * imp[].ext.vai — { pvtk } * * @module modules/paywallsRtdProvider * @requires module:modules/realTimeData @@ -88,8 +89,10 @@ export function getVaiPayload() { /** * Build the canonical ORTB2 split placement from a VAI payload. * - * site.ext.vai — domain provenance + assertion (iss, aud, dom, kid, assertion_jws) - * user.ext.vai — actor classification (vat, act) + * site.ext.vai — domain provenance (iss, dom) + * user.ext.vai — actor classification + signed assertion (iss, vat, act, mstk, jws) + * + * imp-level pvtk enrichment is handled separately in mergeOrtb2Fragments. * * @param {object} vai * @returns {{site: object, user: object}} @@ -100,18 +103,18 @@ export function buildOrtb2(vai) { ext: { vai: { iss: vai.iss, - aud: vai.aud, dom: vai.dom, - kid: vai.kid, - assertion_jws: vai.assertion_jws, } } }, user: { ext: { vai: { + iss: vai.iss, vat: vai.vat, act: vai.act, + mstk: vai.mstk, + jws: vai.jws, } } } @@ -134,6 +137,10 @@ function mergeOrtb2Config(vai) { /** * Merge VAI signals into the request-level ORTB2 fragments. * Used during getBidRequestData. + * + * - site.ext.vai and user.ext.vai are merged into ortb2Fragments.global + * - imp[].ext.vai.pvtk is merged into each ad unit's ortb2Imp (if pvtk is available) + * * @param {object} reqBidsConfigObj * @param {object} vai */ @@ -141,7 +148,18 @@ function mergeOrtb2Fragments(reqBidsConfigObj, vai) { const ortb2 = buildOrtb2(vai); const global = reqBidsConfigObj.ortb2Fragments.global; mergeDeep(global, ortb2); - logInfo(LOG_PREFIX + 'merged ORTB2 fragments. vat=' + vai.vat + ' act=' + vai.act); + + // Enrich imp-level ortb2 with pvtk when available + if (vai.pvtk && reqBidsConfigObj.adUnits) { + reqBidsConfigObj.adUnits.forEach(function (adUnit) { + adUnit.ortb2Imp = adUnit.ortb2Imp || {}; + mergeDeep(adUnit.ortb2Imp, { + ext: { vai: { pvtk: vai.pvtk } } + }); + }); + } + + logInfo(LOG_PREFIX + 'merged ORTB2 fragments. vat=' + vai.vat + ' act=' + vai.act + (vai.pvtk ? ' pvtk=' + vai.pvtk : '')); } // --------------------------------------------------------------------------- diff --git a/modules/paywallsRtdProvider.md b/modules/paywallsRtdProvider.md index c1ba2cbb0a..7dc464dbc8 100644 --- a/modules/paywallsRtdProvider.md +++ b/modules/paywallsRtdProvider.md @@ -12,7 +12,7 @@ The Paywalls RTD module integrates [VAI (Validated Actor Inventory)](https://pay The module automates VAI loading, timing, and signal injection: -- **ORTB2 enrichment** — VAI signals are split across `site.ext.vai` (domain provenance) and `user.ext.vai` (actor classification), available to all ORTB2-native bid adapters. +- **ORTB2 enrichment** — VAI signals are split across `site.ext.vai` (domain provenance), `user.ext.vai` (actor classification + signed assertion), and `imp[].ext.vai` (pageview correlation), available to all ORTB2-native bid adapters. - **GAM targeting** — `vai_vat` and `vai_act` key-value pairs are set per ad unit for Google Ad Manager line item targeting. - **Graceful degradation** — if VAI is unavailable or times out, the auction proceeds normally without enrichment. @@ -66,57 +66,77 @@ In both cases, `vai.js` makes a request to fetch `vai.json`, which contains the ## ORTB2 Output -VAI signals are placed in the global ORTB2 within the `site` and `user` sections: +VAI signals are placed in the global ORTB2 within the `site`, `user`, and `imp` sections: ### `site.ext.vai` — Domain Provenance -Fields that describe the assertion context (who issued it, for which domain): +Fields that describe the assertion context (who issued it, for which domain). The `dom` value can be cryptographically verified through the signed `jws` in `user.ext.vai`. ```json { "site": { "ext": { "vai": { - "iss": "https://paywalls.net", - "aud": "vai", - "dom": "example.com", - "kid": "2026-02-a1b2c3", - "assertion_jws": "eyJhbGciOiJFZERTQSIs..." + "iss": "paywalls.net", + "dom": "example.com" } } } } ``` -| Field | Description | -|-----------------|--------------------------------------------------| -| `iss` | Issuer — always `https://paywalls.net` | -| `aud` | Audience — always `vai` | -| `dom` | Domain the assertion covers | -| `kid` | Key ID for JWS verification via JWKS endpoint | -| `assertion_jws` | Full JWS (compact serialization) for SSP and DSP verification | +| Field | Description | +|-------|--------------------------------------------------| +| `iss` | Issuer — bare domain (e.g. `paywalls.net`) | +| `dom` | Domain the assertion covers | ### `user.ext.vai` — Actor Classification -Fields that describe the classified actor: +Fields that describe the classified actor and the signed assertion: ```json { "user": { "ext": { "vai": { + "iss": "paywalls.net", + "mstk": "01J4X9K2ABCDEF01234567", "vat": "HUMAN", - "act": "ACT-1" + "act": "ACT-1", + "jws": "eyJhbGciOiJFZERTQSIs..." } } } } ``` -| Field | Description | -|-------|--------------------------------------------------------------| -| `vat` | Validated Actor Type — one of `HUMAN`, `AI_AGENT`, `SHARING`, `OTHER` | -| `act` | Actor Confidence Tier — one of `ACT-1`, `ACT-2`, `ACT-3` | +| Field | Description | +|--------|--------------------------------------------------------------| +| `iss` | Issuer — bare domain (e.g. `paywalls.net`) | +| `mstk` | Micro-session token — unique per assertion | +| `vat` | Validated Actor Type — one of `HUMAN`, `AI_AGENT`, `SHARING`, `OTHER` | +| `act` | Actor Confidence Tier — one of `ACT-1`, `ACT-2`, `ACT-3` | +| `jws` | Full JWS (compact serialization) for SSP and DSP verification | + +### `imp[].ext.vai` — Pageview Correlation + +Set on each ad unit's `ortb2Imp` when `pvtk` is available from the VAI payload: + +```json +{ + "imp": [{ + "ext": { + "vai": { + "pvtk": "01J4X9K2ABCDEF01234567/3" + } + } + }] +} +``` + +| Field | Description | +|--------|--------------------------------------------------------------| +| `pvtk` | Pageview token — client-derived, unsigned; correlates impressions within a pageview using the `mstk` root | ## GAM Targeting @@ -156,7 +176,7 @@ pbjs.setConfig({ - **No user identifiers**: VAI does not collect, store, or transmit user IDs, cookies, or fingerprints. - **No PII**: The classification is based on aggregate session-level behavioral signals, not personal data. - **Browser-side only**: All signal extraction runs in the browser; no data leaves the page except the classification result. -- **Signed assertions**: SSPs can independently verify the `assertion_jws` via the JWKS endpoint pulled from the JWS header (typically `https://example.com/pw/jwks.json`), ensuring the classification has not been tampered with. +- **Signed assertions**: SSPs can independently verify the `jws` via the JWKS endpoint pulled from the JWS header (typically `https://example.com/pw/jwks.json`), ensuring the classification has not been tampered with. ## How It Works diff --git a/test/spec/modules/paywallsRtdProvider_spec.js b/test/spec/modules/paywallsRtdProvider_spec.js index b311fc2e72..78fc1092e9 100644 --- a/test/spec/modules/paywallsRtdProvider_spec.js +++ b/test/spec/modules/paywallsRtdProvider_spec.js @@ -18,13 +18,14 @@ import { loadExternalScriptStub } from 'test/mocks/adloaderStub.js'; // --------------------------------------------------------------------------- const MOCK_VAI = { - iss: 'https://paywalls.net', - aud: 'vai', + iss: 'paywalls.net', dom: 'example.com', kid: '2026-01-a', vat: 'HUMAN', act: 'ACT-1', - assertion_jws: 'eyJhbGciOiJFZERTQSJ9.eyJ2YXQiOiJIVU1BTiJ9.signature', + mstk: '01J4X9K2ABCDEF01234567', + jws: 'eyJhbGciOiJFZERTQSJ9.eyJ2YXQiOiJIVU1BTiJ9.signature', + pvtk: '01J4X9K2ABCDEF01234567/1', iat: Math.floor(Date.now() / 1000) - 10, exp: Math.floor(Date.now() / 1000) + 60, }; @@ -47,11 +48,12 @@ const MOCK_CONFIG_WITH_URL = { }, }; -function makeReqBids(existingOrtb2 = {}) { +function makeReqBids(existingOrtb2 = {}, adUnits = [{code: 'ad-unit-1'}, {code: 'ad-unit-2'}]) { return { ortb2Fragments: { global: existingOrtb2, }, + adUnits: adUnits, }; } @@ -140,22 +142,22 @@ describe('paywallsRtdProvider', function () { const ortb2 = buildOrtb2(MOCK_VAI); expect(ortb2.site.ext.vai).to.deep.equal({ iss: MOCK_VAI.iss, - aud: MOCK_VAI.aud, dom: MOCK_VAI.dom, - kid: MOCK_VAI.kid, - assertion_jws: MOCK_VAI.assertion_jws, }); }); it('places user fields at user.ext.vai', function () { const ortb2 = buildOrtb2(MOCK_VAI); expect(ortb2.user.ext.vai).to.deep.equal({ + iss: MOCK_VAI.iss, vat: MOCK_VAI.vat, act: MOCK_VAI.act, + mstk: MOCK_VAI.mstk, + jws: MOCK_VAI.jws, }); }); - it('does not place fields at imp level', function () { + it('does not place fields at imp level (imp handled in mergeOrtb2Fragments)', function () { const ortb2 = buildOrtb2(MOCK_VAI); expect(ortb2).to.not.have.property('imp'); }); @@ -204,9 +206,15 @@ describe('paywallsRtdProvider', function () { paywallsSubmodule.getBidRequestData(reqBids, function () { const global = reqBids.ortb2Fragments.global; expect(global.site.ext.vai.dom).to.equal('example.com'); - expect(global.site.ext.vai.assertion_jws).to.equal(MOCK_VAI.assertion_jws); + expect(global.site.ext.vai.iss).to.equal('paywalls.net'); + expect(global.user.ext.vai.jws).to.equal(MOCK_VAI.jws); + expect(global.user.ext.vai.mstk).to.equal(MOCK_VAI.mstk); expect(global.user.ext.vai.vat).to.equal('HUMAN'); expect(global.user.ext.vai.act).to.equal('ACT-1'); + // pvtk should be set at imp level on each ad unit + reqBids.adUnits.forEach(function (adUnit) { + expect(adUnit.ortb2Imp.ext.vai.pvtk).to.equal(MOCK_VAI.pvtk); + }); expect(loadExternalScriptStub.called).to.be.false; done(); }, MOCK_CONFIG, {}); @@ -318,6 +326,37 @@ describe('paywallsRtdProvider', function () { done(); }, fastConfig, {}); }); + + it('injects pvtk at imp level on each ad unit', function (done) { + window[VAI_WINDOW_KEY] = { ...MOCK_VAI }; + const reqBids = makeReqBids({}, [{code: 'slot-a'}, {code: 'slot-b'}, {code: 'slot-c'}]); + + paywallsSubmodule.getBidRequestData(reqBids, function () { + reqBids.adUnits.forEach(function (adUnit) { + expect(adUnit.ortb2Imp).to.have.nested.property('ext.vai.pvtk'); + expect(adUnit.ortb2Imp.ext.vai.pvtk).to.equal(MOCK_VAI.pvtk); + }); + done(); + }, MOCK_CONFIG, {}); + }); + + it('skips imp-level pvtk when pvtk is absent from VAI payload', function (done) { + const vaiNoPvtk = { ...MOCK_VAI }; + delete vaiNoPvtk.pvtk; + window[VAI_WINDOW_KEY] = vaiNoPvtk; + const reqBids = makeReqBids(); + + paywallsSubmodule.getBidRequestData(reqBids, function () { + // Global ORTB2 should still be enriched + const global = reqBids.ortb2Fragments.global; + expect(global.user.ext.vai.vat).to.equal('HUMAN'); + // But ad units should NOT have ortb2Imp.ext.vai + reqBids.adUnits.forEach(function (adUnit) { + expect(adUnit).to.not.have.nested.property('ortb2Imp.ext.vai'); + }); + done(); + }, MOCK_CONFIG, {}); + }); }); // ------------------------------------------------------------------------- From aa286d0043a172a625911d465a19c5d7d060221a Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Tue, 3 Mar 2026 14:48:51 -0800 Subject: [PATCH 11/22] Paywalls RTD Provider: update mock-vai.js to match VAI refactor field names --- integrationExamples/gpt/mock-vai.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/integrationExamples/gpt/mock-vai.js b/integrationExamples/gpt/mock-vai.js index 1d322c8e9e..32cb2d645a 100644 --- a/integrationExamples/gpt/mock-vai.js +++ b/integrationExamples/gpt/mock-vai.js @@ -19,11 +19,14 @@ vat: 'HUMAN', act: 'ACT-1', // Domain provenance (site.ext.vai) - iss: 'https://paywalls.net', - aud: 'vai', + iss: 'paywalls.net', dom: location.hostname, - kid: 'test-key-001', - assertion_jws: 'eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJodHRwczovL3ZhaS5wYXl3YWxscy5uZXQifQ.mock-signature', + // Signed assertion (user.ext.vai) + jws: 'eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3Qta2V5LTAwMSJ9.eyJpc3MiOiJwYXl3YWxscy5uZXQifQ.mock-signature', + // Meter/session token (user.ext.vai) + mstk: 'mock-mstk-token-001', + // Per-view token (imp[].ext.vai) + pvtk: 'mock-pvtk-token-001', // Expiry — 1 hour from now exp: Math.floor(Date.now() / 1000) + 3600, }; From 93da8e7c55c0240cf9c84750be3c1b22ec3ae61d Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Tue, 3 Mar 2026 15:18:18 -0800 Subject: [PATCH 12/22] Paywalls RTD Provider: never shorten late hook grace window on script onload --- modules/paywallsRtdProvider.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/modules/paywallsRtdProvider.js b/modules/paywallsRtdProvider.js index 325a90cd3c..c38eaacb5e 100644 --- a/modules/paywallsRtdProvider.js +++ b/modules/paywallsRtdProvider.js @@ -226,6 +226,7 @@ function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { let pollId = null; let timeoutId = null; let lateHookCleanupId = null; + let lateHookDeadline = 0; const previousHook = (typeof window[VAI_HOOK_KEY] === 'function') ? window[VAI_HOOK_KEY] : null; @@ -249,9 +250,11 @@ function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { } function installLateHookCapture() { + const delay = Math.max(waitForIt, LATE_HOOK_GRACE_MS); + lateHookDeadline = Date.now() + delay; lateHookCleanupId = setTimeout(function () { restoreHook(); - }, Math.max(waitForIt, LATE_HOOK_GRACE_MS)); + }, delay); } function resolve(vai) { @@ -316,9 +319,13 @@ function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { } else if (resolved && !enriched) { // Script loaded after timeout but hasn't delivered VAI yet. // Extend hook grace so async delivery (e.g. fetch of vai.json) can still reach us. + // Never shorten an already-running longer timer. + const remaining = Math.max(0, lateHookDeadline - Date.now()); + const grace = Math.max(remaining, LATE_HOOK_GRACE_MS); if (lateHookCleanupId != null) { clearTimeout(lateHookCleanupId); } - lateHookCleanupId = setTimeout(function () { restoreHook(); }, LATE_HOOK_GRACE_MS); - logInfo(LOG_PREFIX + 'script loaded post-timeout — extending hook grace by ' + LATE_HOOK_GRACE_MS + 'ms'); + lateHookDeadline = Date.now() + grace; + lateHookCleanupId = setTimeout(function () { restoreHook(); }, grace); + logInfo(LOG_PREFIX + 'script loaded post-timeout — hook grace ' + grace + 'ms'); } }); } catch (e) { From adf48b9f24b8e569b82e1317e523dc88cc0a96f5 Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Wed, 4 Mar 2026 09:23:51 -0800 Subject: [PATCH 13/22] Paywalls RTD Provider: remove timer-based hook cleanup, let hook self-clean on delivery --- modules/paywallsRtdProvider.js | 30 +++---------------- test/spec/modules/paywallsRtdProvider_spec.js | 21 +++++++------ 2 files changed, 14 insertions(+), 37 deletions(-) diff --git a/modules/paywallsRtdProvider.js b/modules/paywallsRtdProvider.js index c38eaacb5e..98dc597bbc 100644 --- a/modules/paywallsRtdProvider.js +++ b/modules/paywallsRtdProvider.js @@ -36,7 +36,6 @@ export const VAI_HOOK_KEY = '__PW_VAI_HOOK__'; export const VAI_LS_KEY = '__pw_vai__'; const DEFAULT_WAIT_FOR_IT = 100; -const LATE_HOOK_GRACE_MS = 1000; // Cached VAI payload from init (for early detection) let cachedVai = null; @@ -225,8 +224,6 @@ function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { let enriched = false; let pollId = null; let timeoutId = null; - let lateHookCleanupId = null; - let lateHookDeadline = 0; const previousHook = (typeof window[VAI_HOOK_KEY] === 'function') ? window[VAI_HOOK_KEY] : null; @@ -238,10 +235,6 @@ function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { delete window[VAI_HOOK_KEY]; } } - if (lateHookCleanupId != null) { - clearTimeout(lateHookCleanupId); - lateHookCleanupId = null; - } } function cleanup() { @@ -249,14 +242,6 @@ function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { if (timeoutId != null) { clearTimeout(timeoutId); timeoutId = null; } } - function installLateHookCapture() { - const delay = Math.max(waitForIt, LATE_HOOK_GRACE_MS); - lateHookDeadline = Date.now() + delay; - lateHookCleanupId = setTimeout(function () { - restoreHook(); - }, delay); - } - function resolve(vai) { const validVai = !!(vai && isValid(vai)); if (resolved) { @@ -277,7 +262,10 @@ function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { restoreHook(); mergeOrtb2Fragments(reqBidsConfigObj, vai); } else { - installLateHookCapture(); + // Hook stays installed — no timer-based removal. + // It will self-clean when vai.js eventually delivers a valid payload + // (captured in the post-resolve branch above), ensuring subsequent + // auctions can use the data regardless of how late it arrives. logWarn(LOG_PREFIX + 'VAI unavailable — proceeding without enrichment'); } callback(); @@ -316,16 +304,6 @@ function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { const vai = window[VAI_WINDOW_KEY]; if (vai && isValid(vai)) { resolve(vai); - } else if (resolved && !enriched) { - // Script loaded after timeout but hasn't delivered VAI yet. - // Extend hook grace so async delivery (e.g. fetch of vai.json) can still reach us. - // Never shorten an already-running longer timer. - const remaining = Math.max(0, lateHookDeadline - Date.now()); - const grace = Math.max(remaining, LATE_HOOK_GRACE_MS); - if (lateHookCleanupId != null) { clearTimeout(lateHookCleanupId); } - lateHookDeadline = Date.now() + grace; - lateHookCleanupId = setTimeout(function () { restoreHook(); }, grace); - logInfo(LOG_PREFIX + 'script loaded post-timeout — hook grace ' + grace + 'ms'); } }); } catch (e) { diff --git a/test/spec/modules/paywallsRtdProvider_spec.js b/test/spec/modules/paywallsRtdProvider_spec.js index 78fc1092e9..f4acece649 100644 --- a/test/spec/modules/paywallsRtdProvider_spec.js +++ b/test/spec/modules/paywallsRtdProvider_spec.js @@ -435,10 +435,10 @@ describe('paywallsRtdProvider', function () { // If we get here without done() being called, the test will timeout }); - it('should extend hook grace when script loads after timeout', function (done) { + it('should keep hook alive indefinitely after timeout for late delivery', function (done) { // Scenario: slow network — script loads well after waitForIt timeout. - // Hook delivery happens 50ms after script load, which is beyond the - // initial grace window. The onload extension should keep the hook alive. + // Hook delivery happens long after timeout. The hook is never removed + // on a timer, so it captures the payload regardless of delay. const reqBids1 = makeReqBids(); const fastConfig = { name: SUBMODULE_NAME, params: { waitForIt: 10 } }; let scriptOnload; @@ -452,15 +452,14 @@ describe('paywallsRtdProvider', function () { paywallsSubmodule.getBidRequestData(reqBids1, function () { // First auction degrades (expected) - // Simulate script loading 500ms after timeout (well past initial grace) - clock.tick(500); - scriptOnload(); // fires onload — should extend hook grace + // Simulate script loading 5s after timeout + clock.tick(5000); + scriptOnload(); - // Hook delivers VAI 50ms after script load - clock.tick(50); - if (typeof window[VAI_HOOK_KEY] === 'function') { - window[VAI_HOOK_KEY]({ ...MOCK_VAI }); - } + // Hook delivers VAI 3s after script load (8s total — well past any grace timer) + clock.tick(3000); + expect(typeof window[VAI_HOOK_KEY]).to.equal('function'); + window[VAI_HOOK_KEY]({ ...MOCK_VAI }); // Verify the late payload was stored expect(window[VAI_WINDOW_KEY]).to.have.property('vat', 'HUMAN'); From e30638a837783818c965ec6d296d352fc56cd9df Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Wed, 4 Mar 2026 10:26:47 -0800 Subject: [PATCH 14/22] Paywalls RTD Provider: remove hook chaining to prevent closure accumulation --- modules/paywallsRtdProvider.js | 18 +++----- test/spec/modules/paywallsRtdProvider_spec.js | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/modules/paywallsRtdProvider.js b/modules/paywallsRtdProvider.js index 98dc597bbc..e079f6e273 100644 --- a/modules/paywallsRtdProvider.js +++ b/modules/paywallsRtdProvider.js @@ -225,15 +225,9 @@ function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { let pollId = null; let timeoutId = null; - const previousHook = (typeof window[VAI_HOOK_KEY] === 'function') ? window[VAI_HOOK_KEY] : null; - function restoreHook() { if (window[VAI_HOOK_KEY] === hookHandler) { - if (previousHook) { - window[VAI_HOOK_KEY] = previousHook; - } else { - delete window[VAI_HOOK_KEY]; - } + delete window[VAI_HOOK_KEY]; } } @@ -271,13 +265,13 @@ function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { callback(); } - // Set up the hook that vai.js may call - // Preserve any existing hook set by the page + // Set up the hook that vai.js may call. + // __PW_VAI_HOOK__ is owned by this module — we overwrite unconditionally + // (consistent with Prebid.js conventions; see medianet, gamera, brandmetrics). + // If our own handler from a prior degraded auction is still installed, we + // simply replace it (no chaining) to prevent unbounded closure accumulation. function hookHandler(vaiData) { resolve(vaiData); - if (previousHook) { - try { previousHook(vaiData); } catch (e) { logWarn(LOG_PREFIX + 'Error in existing VAI hook:', e); } - } } window[VAI_HOOK_KEY] = hookHandler; diff --git a/test/spec/modules/paywallsRtdProvider_spec.js b/test/spec/modules/paywallsRtdProvider_spec.js index f4acece649..41314b8542 100644 --- a/test/spec/modules/paywallsRtdProvider_spec.js +++ b/test/spec/modules/paywallsRtdProvider_spec.js @@ -477,6 +477,49 @@ describe('paywallsRtdProvider', function () { // Advance past timeout clock.tick(15); }); + + it('should not chain hooks across multiple degraded auctions', function (done) { + // Scenario: VAI never arrives, multiple auctions fire. + // Each auction should replace (not wrap) the hook to prevent unbounded closure growth. + const fastConfig = { name: SUBMODULE_NAME, params: { waitForIt: 10 } }; + let auctionsDegraded = 0; + + loadExternalScriptStub.callsFake(() => { + // Script never delivers VAI + }); + + paywallsSubmodule.init(fastConfig, {}); + + function runDegradedAuction(cb) { + const reqBids = makeReqBids(); + paywallsSubmodule.getBidRequestData(reqBids, function () { + auctionsDegraded++; + cb(); + }, fastConfig, {}); + clock.tick(15); + } + + // Run 5 degraded auctions + runDegradedAuction(() => { + const hook1 = window[VAI_HOOK_KEY]; + runDegradedAuction(() => { + const hook2 = window[VAI_HOOK_KEY]; + // Hook should be replaced, not chained — different function each time + expect(hook2).to.not.equal(hook1); + runDegradedAuction(() => { + runDegradedAuction(() => { + runDegradedAuction(() => { + expect(auctionsDegraded).to.equal(5); + // Deliver VAI via the current hook — should not cause deep recursion + window[VAI_HOOK_KEY]({ ...MOCK_VAI }); + expect(window[VAI_WINDOW_KEY]).to.have.property('vat', 'HUMAN'); + done(); + }); + }); + }); + }); + }); + }); }); // ------------------------------------------------------------------------- From 2a90533a634688dccd76f4353fe250eb87019874 Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Wed, 4 Mar 2026 19:04:52 -0800 Subject: [PATCH 15/22] Paywalls RTD Provider: update integration example for VAI schema changes Remove aud, kid, assertion_jws from site.ext.vai assertions. Use jws field (renamed from assertion_jws) in validation check. Aligns integration example with current VAI payload structure. --- integrationExamples/gpt/paywallsRtdProvider_example.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/integrationExamples/gpt/paywallsRtdProvider_example.html b/integrationExamples/gpt/paywallsRtdProvider_example.html index d4582aecd0..d0d62d00ba 100644 --- a/integrationExamples/gpt/paywallsRtdProvider_example.html +++ b/integrationExamples/gpt/paywallsRtdProvider_example.html @@ -4,7 +4,7 @@ Uses mock-vai.js to simulate VAI classification. Open browser DevTools console to check: ✓ vai.js loaded by RTD module - ✓ ortb2.site.ext.vai contains { iss, aud, dom, kid, assertion_jws } + ✓ ortb2.site.ext.vai contains { iss, dom, jws } ✓ ortb2.user.ext.vai contains { vat, act } ✓ getTargetingData returns { vai_vat, vai_act } per ad unit ✓ Auction completes within timing budget @@ -87,8 +87,8 @@ setStatus('test-site-vai', false, 'site.ext.vai should be absent in degrade mode but found: ' + JSON.stringify(siteVai)); } } else { - if (siteVai && siteVai.iss && siteVai.aud && siteVai.dom && siteVai.kid && siteVai.assertion_jws) { - setStatus('test-site-vai', true, 'site.ext.vai: iss=' + siteVai.iss + ' aud=' + siteVai.aud + ' dom=' + siteVai.dom + ' kid=' + siteVai.kid); + if (siteVai && siteVai.iss && siteVai.dom && siteVai.jws) { + setStatus('test-site-vai', true, 'site.ext.vai: iss=' + siteVai.iss + ' dom=' + siteVai.dom); } else { setStatus('test-site-vai', false, 'site.ext.vai missing or incomplete: ' + JSON.stringify(siteVai)); } From b7046c709092e6bdd9c633aa89fd3c06f7af8361 Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Tue, 10 Mar 2026 09:55:23 -0700 Subject: [PATCH 16/22] Paywalls Adapters: fix object-curly-spacing lint errors --- modules/paywallsAnalyticsAdapter.js | 18 +++++++++--------- modules/paywallsRtdProvider.js | 12 ++++++------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/modules/paywallsAnalyticsAdapter.js b/modules/paywallsAnalyticsAdapter.js index 09c1b6fd80..2943cd3f09 100644 --- a/modules/paywallsAnalyticsAdapter.js +++ b/modules/paywallsAnalyticsAdapter.js @@ -15,10 +15,10 @@ import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; import adapterManager from '../src/adapterManager.js'; -import {EVENTS} from '../src/constants.js'; -import {logInfo, logWarn} from '../src/utils.js'; -import {loadExternalScript} from '../src/adloader.js'; -import {MODULE_TYPE_ANALYTICS} from '../src/activities/modules.js'; +import { EVENTS } from '../src/constants.js'; +import { logInfo, logWarn } from '../src/utils.js'; +import { loadExternalScript } from '../src/adloader.js'; +import { MODULE_TYPE_ANALYTICS } from '../src/activities/modules.js'; const ADAPTER_CODE = 'paywalls'; const LOG_PREFIX = '[PaywallsAnalytics] '; @@ -26,7 +26,7 @@ const LOG_PREFIX = '[PaywallsAnalytics] '; export const DEFAULT_SCRIPT_URL = '/pw/vai.js'; export const VAI_WINDOW_KEY = '__PW_VAI__'; -const {AUCTION_END} = EVENTS; +const { AUCTION_END } = EVENTS; // Track which auctions we've already emitted for (de-dup) let emittedAuctions = Object.create(null); @@ -62,7 +62,7 @@ export function getVaiClassification() { if (typeof vai.exp === 'number' && vai.exp < Math.floor(Date.now() / 1000)) { return null; } - return {vat: vai.vat, act: vai.act}; + return { vat: vai.vat, act: vai.act }; } return null; } @@ -133,7 +133,7 @@ export function emitMetrics(kvps) { case 'dataLayer': window.dataLayer = window.dataLayer || []; - window.dataLayer.push(Object.assign({event: 'vai_auction'}, kvps)); + window.dataLayer.push(Object.assign({ event: 'vai_auction' }, kvps)); break; case 'callback': @@ -162,7 +162,7 @@ export function emitMetrics(kvps) { * We only care about AUCTION_END — emit classification once per auction. * @param {{ eventType: string, args: object }} param */ -function handleEvent({eventType, args}) { +function handleEvent({ eventType, args }) { if (!sampledIn) return; if (eventType !== AUCTION_END) return; @@ -182,7 +182,7 @@ function handleEvent({eventType, args}) { // --------------------------------------------------------------------------- const paywallsAnalytics = Object.assign( - adapter({analyticsType: 'bundle'}), + adapter({ analyticsType: 'bundle' }), { track: handleEvent, } diff --git a/modules/paywallsRtdProvider.js b/modules/paywallsRtdProvider.js index e079f6e273..11ac527542 100644 --- a/modules/paywallsRtdProvider.js +++ b/modules/paywallsRtdProvider.js @@ -16,11 +16,11 @@ * @see https://paywalls.net/docs/publishers/vai */ -import {submodule} from '../src/hook.js'; -import {mergeDeep, logInfo, logWarn} from '../src/utils.js'; -import {loadExternalScript} from '../src/adloader.js'; -import {MODULE_TYPE_RTD} from '../src/activities/modules.js'; -import {getStorageManager} from '../src/storageManager.js'; +import { submodule } from '../src/hook.js'; +import { mergeDeep, logInfo, logWarn } from '../src/utils.js'; +import { loadExternalScript } from '../src/adloader.js'; +import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; +import { getStorageManager } from '../src/storageManager.js'; /** * @typedef {import('../modules/rtdModule/index.js').RtdSubmodule} RtdSubmodule @@ -40,7 +40,7 @@ const DEFAULT_WAIT_FOR_IT = 100; // Cached VAI payload from init (for early detection) let cachedVai = null; -export const storage = getStorageManager({moduleType: MODULE_TYPE_RTD, moduleName: SUBMODULE_NAME}); +export const storage = getStorageManager({ moduleType: MODULE_TYPE_RTD, moduleName: SUBMODULE_NAME }); // --------------------------------------------------------------------------- // Helpers From e17a3237ca94f4cb8b2bfcb11db6f6acd3bc7292 Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Tue, 10 Mar 2026 15:07:44 -0700 Subject: [PATCH 17/22] Paywalls RTD Provider: rename params.waitForIt to params.timeout; fix site.ext.vai.jws reference - Rename params.waitForIt to params.timeout to avoid collision with RTD framework's Boolean waitForIt (reviewer feedback) - Fix integration example: site.ext.vai only has {iss, dom}, jws lives on user.ext.vai - Update docs and tests to match --- .../gpt/paywallsRtdProvider_example.html | 6 ++--- modules/paywallsRtdProvider.js | 14 +++++------ modules/paywallsRtdProvider.md | 2 +- test/spec/modules/paywallsRtdProvider_spec.js | 24 +++++++++---------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/integrationExamples/gpt/paywallsRtdProvider_example.html b/integrationExamples/gpt/paywallsRtdProvider_example.html index d0d62d00ba..be4ec33edc 100644 --- a/integrationExamples/gpt/paywallsRtdProvider_example.html +++ b/integrationExamples/gpt/paywallsRtdProvider_example.html @@ -4,8 +4,8 @@ Uses mock-vai.js to simulate VAI classification. Open browser DevTools console to check: ✓ vai.js loaded by RTD module - ✓ ortb2.site.ext.vai contains { iss, dom, jws } - ✓ ortb2.user.ext.vai contains { vat, act } + ✓ ortb2.site.ext.vai contains { iss, dom } + ✓ ortb2.user.ext.vai contains { vat, act, jws } ✓ getTargetingData returns { vai_vat, vai_act } per ad unit ✓ Auction completes within timing budget @@ -87,7 +87,7 @@ setStatus('test-site-vai', false, 'site.ext.vai should be absent in degrade mode but found: ' + JSON.stringify(siteVai)); } } else { - if (siteVai && siteVai.iss && siteVai.dom && siteVai.jws) { + if (siteVai && siteVai.iss && siteVai.dom) { setStatus('test-site-vai', true, 'site.ext.vai: iss=' + siteVai.iss + ' dom=' + siteVai.dom); } else { setStatus('test-site-vai', false, 'site.ext.vai missing or incomplete: ' + JSON.stringify(siteVai)); diff --git a/modules/paywallsRtdProvider.js b/modules/paywallsRtdProvider.js index 11ac527542..717ba2e3a4 100644 --- a/modules/paywallsRtdProvider.js +++ b/modules/paywallsRtdProvider.js @@ -189,7 +189,7 @@ function init(rtdConfig, userConsent) { * getBidRequestData — called before each auction. * * If VAI is already available, merges ORTB2 and calls callback immediately. - * Otherwise, injects vai.js via loadExternalScript and waits (up to waitForIt ms) + * Otherwise, injects vai.js via loadExternalScript and waits (up to timeout ms) * for window.__PW_VAI__ to be populated (via script execution or __PW_VAI_HOOK__). * * CRITICAL: callback() MUST be called to unblock the auction. On timeout or @@ -212,12 +212,12 @@ function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { // Slow path: need to inject vai.js and wait const params = (rtdConfig && rtdConfig.params) || {}; const scriptUrl = params.scriptUrl || DEFAULT_SCRIPT_URL; - const rawWaitForIt = params.waitForIt; - const waitForIt = (typeof rawWaitForIt === 'number' && rawWaitForIt >= 0) - ? rawWaitForIt + const rawTimeout = params.timeout; + const timeout = (typeof rawTimeout === 'number' && rawTimeout >= 0) + ? rawTimeout : DEFAULT_WAIT_FOR_IT; - if (rawWaitForIt != null && (typeof rawWaitForIt !== 'number' || rawWaitForIt < 0)) { - logWarn(LOG_PREFIX + 'Invalid waitForIt value (' + rawWaitForIt + '); using default ' + DEFAULT_WAIT_FOR_IT); + if (rawTimeout != null && (typeof rawTimeout !== 'number' || rawTimeout < 0)) { + logWarn(LOG_PREFIX + 'Invalid timeout value (' + rawTimeout + '); using default ' + DEFAULT_WAIT_FOR_IT); } let resolved = false; @@ -287,7 +287,7 @@ function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { // Set up timeout (graceful degradation — never block the auction) timeoutId = setTimeout(function () { resolve(null); - }, waitForIt); + }, timeout); // Inject the script try { diff --git a/modules/paywallsRtdProvider.md b/modules/paywallsRtdProvider.md index 7dc464dbc8..9bdafbc6c8 100644 --- a/modules/paywallsRtdProvider.md +++ b/modules/paywallsRtdProvider.md @@ -53,7 +53,7 @@ pbjs.setConfig({ | `waitForIt` | `Boolean` | Optional | Should be `true` when `auctionDelay` is set | `false` | | `params` | `Object` | Optional | Provider configuration | `{}` | | `params.scriptUrl` | `String` | Optional | URL of the VAI loader script | `'/pw/vai.js'` | -| `params.waitForIt` | `Number` | Optional | Max ms to wait for VAI before releasing the auction (distinct from the Boolean `waitForIt` above) | `100` | +| `params.timeout` | `Number` | Optional | Max ms to wait for VAI before releasing the auction | `100` | ### Hosting Modes diff --git a/test/spec/modules/paywallsRtdProvider_spec.js b/test/spec/modules/paywallsRtdProvider_spec.js index 41314b8542..eab58547e4 100644 --- a/test/spec/modules/paywallsRtdProvider_spec.js +++ b/test/spec/modules/paywallsRtdProvider_spec.js @@ -44,11 +44,11 @@ const MOCK_CONFIG_WITH_URL = { name: SUBMODULE_NAME, params: { scriptUrl: 'https://cdn.example.com/pw/vai.js', - waitForIt: 50, + timeout: 50, }, }; -function makeReqBids(existingOrtb2 = {}, adUnits = [{code: 'ad-unit-1'}, {code: 'ad-unit-2'}]) { +function makeReqBids(existingOrtb2 = {}, adUnits = [{ code: 'ad-unit-1' }, { code: 'ad-unit-2' }]) { return { ortb2Fragments: { global: existingOrtb2, @@ -271,7 +271,7 @@ describe('paywallsRtdProvider', function () { const reqBids = makeReqBids(); const fastConfig = { name: SUBMODULE_NAME, - params: { waitForIt: 10 }, + params: { timeout: 10 }, }; // loadExternalScript does NOT set __PW_VAI__ — simulating timeout @@ -313,7 +313,7 @@ describe('paywallsRtdProvider', function () { const reqBids = makeReqBids(); const fastConfig = { name: SUBMODULE_NAME, - params: { waitForIt: 10 }, + params: { timeout: 10 }, }; // Simulate script failing to load entirely @@ -329,7 +329,7 @@ describe('paywallsRtdProvider', function () { it('injects pvtk at imp level on each ad unit', function (done) { window[VAI_WINDOW_KEY] = { ...MOCK_VAI }; - const reqBids = makeReqBids({}, [{code: 'slot-a'}, {code: 'slot-b'}, {code: 'slot-c'}]); + const reqBids = makeReqBids({}, [{ code: 'slot-a' }, { code: 'slot-b' }, { code: 'slot-c' }]); paywallsSubmodule.getBidRequestData(reqBids, function () { reqBids.adUnits.forEach(function (adUnit) { @@ -378,7 +378,7 @@ describe('paywallsRtdProvider', function () { // Bug: if timeout fires before hook, the payload is dropped permanently. // Subsequent auctions keep degrading because loadExternalScript won't re-execute. const reqBids1 = makeReqBids(); - const fastConfig = { name: SUBMODULE_NAME, params: { waitForIt: 10 } }; + const fastConfig = { name: SUBMODULE_NAME, params: { timeout: 10 } }; loadExternalScriptStub.callsFake(() => { // Simulate hook-only delivery AFTER timeout (no window global fallback) @@ -413,10 +413,10 @@ describe('paywallsRtdProvider', function () { clock.tick(15); }); - it('waitForIt=0 should be respected, not treated as falsy', function (done) { - // Bug: params.waitForIt || DEFAULT_WAIT_FOR_IT treats 0 as falsy + it('timeout=0 should be respected, not treated as falsy', function (done) { + // Bug: params.timeout || DEFAULT_WAIT_FOR_IT treats 0 as falsy const reqBids = makeReqBids(); - const zeroConfig = { name: SUBMODULE_NAME, params: { waitForIt: 0 } }; + const zeroConfig = { name: SUBMODULE_NAME, params: { timeout: 0 } }; let callbackTime = null; loadExternalScriptStub.callsFake(() => { @@ -436,11 +436,11 @@ describe('paywallsRtdProvider', function () { }); it('should keep hook alive indefinitely after timeout for late delivery', function (done) { - // Scenario: slow network — script loads well after waitForIt timeout. + // Scenario: slow network — script loads well after timeout. // Hook delivery happens long after timeout. The hook is never removed // on a timer, so it captures the payload regardless of delay. const reqBids1 = makeReqBids(); - const fastConfig = { name: SUBMODULE_NAME, params: { waitForIt: 10 } }; + const fastConfig = { name: SUBMODULE_NAME, params: { timeout: 10 } }; let scriptOnload; loadExternalScriptStub.callsFake((_url, _type, _name, onload) => { @@ -481,7 +481,7 @@ describe('paywallsRtdProvider', function () { it('should not chain hooks across multiple degraded auctions', function (done) { // Scenario: VAI never arrives, multiple auctions fire. // Each auction should replace (not wrap) the hook to prevent unbounded closure growth. - const fastConfig = { name: SUBMODULE_NAME, params: { waitForIt: 10 } }; + const fastConfig = { name: SUBMODULE_NAME, params: { timeout: 10 } }; let auctionsDegraded = 0; loadExternalScriptStub.callsFake(() => { From 3c24357c5240d82a2ab1182d8ace67e5f06f3c94 Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Wed, 11 Mar 2026 13:19:39 -0700 Subject: [PATCH 18/22] Paywalls RTD Adapter: place VAI data at ext.data per Prebid FPD convention --- modules/paywallsRtdProvider.js | 30 ++++++------ test/spec/modules/paywallsRtdProvider_spec.js | 46 +++++++++---------- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/modules/paywallsRtdProvider.js b/modules/paywallsRtdProvider.js index 717ba2e3a4..158c8c90fb 100644 --- a/modules/paywallsRtdProvider.js +++ b/modules/paywallsRtdProvider.js @@ -7,8 +7,8 @@ * automates VAI loading, timing, and ORTB2 injection. * * ORTB2 placement (canonical split): - * site.ext.vai — { iss, dom } - * user.ext.vai — { iss, mstk, vat, act, jws } + * site.ext.data.vai — { iss, dom } + * user.ext.data.vai — { iss, mstk, vat, act, jws } * imp[].ext.vai — { pvtk } * * @module modules/paywallsRtdProvider @@ -88,8 +88,8 @@ export function getVaiPayload() { /** * Build the canonical ORTB2 split placement from a VAI payload. * - * site.ext.vai — domain provenance (iss, dom) - * user.ext.vai — actor classification + signed assertion (iss, vat, act, mstk, jws) + * site.ext.data.vai — domain provenance (iss, dom) + * user.ext.data.vai — actor classification + signed assertion (iss, vat, act, mstk, jws) * * imp-level pvtk enrichment is handled separately in mergeOrtb2Fragments. * @@ -100,20 +100,24 @@ export function buildOrtb2(vai) { return { site: { ext: { - vai: { - iss: vai.iss, - dom: vai.dom, + data: { + vai: { + iss: vai.iss, + dom: vai.dom, + } } } }, user: { ext: { - vai: { - iss: vai.iss, - vat: vai.vat, - act: vai.act, - mstk: vai.mstk, - jws: vai.jws, + data: { + vai: { + iss: vai.iss, + vat: vai.vat, + act: vai.act, + mstk: vai.mstk, + jws: vai.jws, + } } } } diff --git a/test/spec/modules/paywallsRtdProvider_spec.js b/test/spec/modules/paywallsRtdProvider_spec.js index eab58547e4..53715fbc27 100644 --- a/test/spec/modules/paywallsRtdProvider_spec.js +++ b/test/spec/modules/paywallsRtdProvider_spec.js @@ -113,8 +113,8 @@ describe('paywallsRtdProvider', function () { const reqBids = makeReqBids(); paywallsSubmodule.getBidRequestData(reqBids, function () { const global = reqBids.ortb2Fragments.global; - expect(global.site.ext.vai).to.include({ dom: 'example.com' }); - expect(global.user.ext.vai).to.include({ vat: 'HUMAN', act: 'ACT-1' }); + expect(global.site.ext.data.vai).to.include({ dom: 'example.com' }); + expect(global.user.ext.data.vai).to.include({ vat: 'HUMAN', act: 'ACT-1' }); }, MOCK_CONFIG, {}); }); @@ -138,17 +138,17 @@ describe('paywallsRtdProvider', function () { // ------------------------------------------------------------------------- describe('buildOrtb2', function () { - it('places site fields at site.ext.vai', function () { + it('places site fields at site.ext.data.vai', function () { const ortb2 = buildOrtb2(MOCK_VAI); - expect(ortb2.site.ext.vai).to.deep.equal({ + expect(ortb2.site.ext.data.vai).to.deep.equal({ iss: MOCK_VAI.iss, dom: MOCK_VAI.dom, }); }); - it('places user fields at user.ext.vai', function () { + it('places user fields at user.ext.data.vai', function () { const ortb2 = buildOrtb2(MOCK_VAI); - expect(ortb2.user.ext.vai).to.deep.equal({ + expect(ortb2.user.ext.data.vai).to.deep.equal({ iss: MOCK_VAI.iss, vat: MOCK_VAI.vat, act: MOCK_VAI.act, @@ -205,12 +205,12 @@ describe('paywallsRtdProvider', function () { paywallsSubmodule.getBidRequestData(reqBids, function () { const global = reqBids.ortb2Fragments.global; - expect(global.site.ext.vai.dom).to.equal('example.com'); - expect(global.site.ext.vai.iss).to.equal('paywalls.net'); - expect(global.user.ext.vai.jws).to.equal(MOCK_VAI.jws); - expect(global.user.ext.vai.mstk).to.equal(MOCK_VAI.mstk); - expect(global.user.ext.vai.vat).to.equal('HUMAN'); - expect(global.user.ext.vai.act).to.equal('ACT-1'); + expect(global.site.ext.data.vai.dom).to.equal('example.com'); + expect(global.site.ext.data.vai.iss).to.equal('paywalls.net'); + expect(global.user.ext.data.vai.jws).to.equal(MOCK_VAI.jws); + expect(global.user.ext.data.vai.mstk).to.equal(MOCK_VAI.mstk); + expect(global.user.ext.data.vai.vat).to.equal('HUMAN'); + expect(global.user.ext.data.vai.act).to.equal('ACT-1'); // pvtk should be set at imp level on each ad unit reqBids.adUnits.forEach(function (adUnit) { expect(adUnit.ortb2Imp.ext.vai.pvtk).to.equal(MOCK_VAI.pvtk); @@ -231,7 +231,7 @@ describe('paywallsRtdProvider', function () { // Original data should be preserved expect(global.site.name).to.equal('MySite'); // VAI data should be merged - expect(global.site.ext.vai.dom).to.equal('example.com'); + expect(global.site.ext.data.vai.dom).to.equal('example.com'); done(); }, MOCK_CONFIG, {}); }); @@ -248,7 +248,7 @@ describe('paywallsRtdProvider', function () { paywallsSubmodule.getBidRequestData(reqBids, function () { expect(loadExternalScriptStub.calledOnce).to.be.true; const global = reqBids.ortb2Fragments.global; - expect(global.site.ext.vai.dom).to.equal('example.com'); + expect(global.site.ext.data.vai.dom).to.equal('example.com'); done(); }, MOCK_CONFIG, {}); }); @@ -283,7 +283,7 @@ describe('paywallsRtdProvider', function () { paywallsSubmodule.getBidRequestData(reqBids, function () { // Callback should fire even without VAI data — graceful degradation const global = reqBids.ortb2Fragments.global; - expect(global).to.not.have.nested.property('site.ext.vai'); + expect(global).to.not.have.nested.property('site.ext.data.vai'); done(); }, fastConfig, {}); }); @@ -303,8 +303,8 @@ describe('paywallsRtdProvider', function () { paywallsSubmodule.getBidRequestData(reqBids, function () { const global = reqBids.ortb2Fragments.global; - expect(global.site.ext.vai.dom).to.equal('example.com'); - expect(global.user.ext.vai.vat).to.equal('HUMAN'); + expect(global.site.ext.data.vai.dom).to.equal('example.com'); + expect(global.user.ext.data.vai.vat).to.equal('HUMAN'); done(); }, MOCK_CONFIG, {}); }); @@ -349,7 +349,7 @@ describe('paywallsRtdProvider', function () { paywallsSubmodule.getBidRequestData(reqBids, function () { // Global ORTB2 should still be enriched const global = reqBids.ortb2Fragments.global; - expect(global.user.ext.vai.vat).to.equal('HUMAN'); + expect(global.user.ext.data.vai.vat).to.equal('HUMAN'); // But ad units should NOT have ortb2Imp.ext.vai reqBids.adUnits.forEach(function (adUnit) { expect(adUnit).to.not.have.nested.property('ortb2Imp.ext.vai'); @@ -394,7 +394,7 @@ describe('paywallsRtdProvider', function () { paywallsSubmodule.getBidRequestData(reqBids1, function () { // First auction degraded — expected const global1 = reqBids1.ortb2Fragments.global; - expect(global1).to.not.have.nested.property('site.ext.vai'); + expect(global1).to.not.have.nested.property('site.ext.data.vai'); // Now hook delivers late payload (at t=20ms) clock.tick(15); @@ -403,8 +403,8 @@ describe('paywallsRtdProvider', function () { const reqBids2 = makeReqBids(); paywallsSubmodule.getBidRequestData(reqBids2, function () { const global2 = reqBids2.ortb2Fragments.global; - expect(global2.user.ext.vai.vat).to.equal('HUMAN'); - expect(global2.user.ext.vai.act).to.equal('ACT-1'); + expect(global2.user.ext.data.vai.vat).to.equal('HUMAN'); + expect(global2.user.ext.data.vai.act).to.equal('ACT-1'); done(); }, fastConfig, {}); }, fastConfig, {}); @@ -468,8 +468,8 @@ describe('paywallsRtdProvider', function () { const reqBids2 = makeReqBids(); paywallsSubmodule.getBidRequestData(reqBids2, function () { const global2 = reqBids2.ortb2Fragments.global; - expect(global2.user.ext.vai.vat).to.equal('HUMAN'); - expect(global2.user.ext.vai.act).to.equal('ACT-1'); + expect(global2.user.ext.data.vai.vat).to.equal('HUMAN'); + expect(global2.user.ext.data.vai.act).to.equal('ACT-1'); done(); }, fastConfig, {}); }, fastConfig, {}); From fcb8d2bb657614e0336d1eb4e2f9773059c4668b Mon Sep 17 00:00:00 2001 From: paywalls-mike Date: Fri, 13 Mar 2026 10:57:31 -0700 Subject: [PATCH 19/22] Paywalls RTD/Analytics Adapters: remove vai.js script injection Remove loadExternalScript-based vai.js injection from both modules. Publishers now load vai.js via a standard script tag before Prebid initializes. Both modules read window.__PW_VAI__ directly. RTD Provider: - Remove loadExternalScript, getStorageManager, polling/hook/timeout - getBidRequestData reads window.__PW_VAI__ synchronously - Place VAI data under site.ext.data.vai / user.ext.data.vai (FPD convention) - imp[].ext.vai unchanged (not first-party data) - 22 tests rewritten and passing Analytics Adapter: - Remove loadExternalScript, ensureVai(), scriptUrl config - getVaiClassification reads window.__PW_VAI__ directly - 29 tests rewritten and passing Integration Examples: - Both example pages load vai.js via script tag - Support ?real (production), ?degrade (no vai.js), default (mock) - Remove scriptUrl/waitForIt/auctionDelay from configs --- .../gpt/paywallsAnalyticsAdapter_example.html | 54 +-- .../gpt/paywallsRtdProvider_example.html | 89 ++--- modules/paywallsAnalyticsAdapter.js | 50 +-- modules/paywallsRtdProvider.js | 185 ++-------- .../modules/paywallsAnalyticsAdapter_spec.js | 175 +++------- test/spec/modules/paywallsRtdProvider_spec.js | 326 ++---------------- 6 files changed, 180 insertions(+), 699 deletions(-) diff --git a/integrationExamples/gpt/paywallsAnalyticsAdapter_example.html b/integrationExamples/gpt/paywallsAnalyticsAdapter_example.html index aa13b6e2e4..ff45e09bc2 100644 --- a/integrationExamples/gpt/paywallsAnalyticsAdapter_example.html +++ b/integrationExamples/gpt/paywallsAnalyticsAdapter_example.html @@ -5,8 +5,16 @@ on each auction. Uses a single appnexus test placement (nobid is fine — the adapter fires on AUCTION_END regardless of bid outcome). - Serve with: npx gulp serve-fast --modules=rtdModule,paywallsRtdProvider,paywallsAnalyticsAdapter,appnexusBidAdapter + Publishers must load vai.js before Prebid.js initializes. + The analytics adapter reads window.__PW_VAI__ directly. + + Serve with: npx gulp serve-fast --modules=paywallsAnalyticsAdapter,appnexusBidAdapter Then open: http://localhost:9999/integrationExamples/gpt/paywallsAnalyticsAdapter_example.html + + Modes: + ?degrade — skip vai.js loading to test graceful degradation + ?real — use real VAI from production (requires network) + (default) — use mock-vai.js for deterministic testing --> @@ -20,18 +28,35 @@ .wait { border-left-color: #f39c12; background: #fef9e7; } + + + + + + + + + + + @@ -53,12 +80,8 @@ // ─── Test harness ─────────────────────────────────────────── var testResults = {}; var auctionStart; - var degradeMode = location.search.indexOf('degrade') !== -1; - var realMode = location.search.indexOf('real') !== -1; - // In degrade mode, clear localStorage so there's no cached VAI if (degradeMode) { - try { localStorage.removeItem('__pw_vai__'); } catch (e) {} delete window.__PW_VAI__; document.title = 'Paywalls RTD — Degradation Test'; } @@ -78,35 +101,35 @@ function runAssertions(ortb2, targeting) { var elapsed = Date.now() - auctionStart; - // 1. ORTB2 site.ext.vai - var siteVai = ortb2 && ortb2.site && ortb2.site.ext && ortb2.site.ext.vai; + // 1. ORTB2 site.ext.data.vai + var siteVai = ortb2 && ortb2.site && ortb2.site.ext && ortb2.site.ext.data && ortb2.site.ext.data.vai; if (degradeMode) { if (!siteVai) { - setStatus('test-site-vai', true, 'site.ext.vai absent (expected — degraded mode)'); + setStatus('test-site-vai', true, 'site.ext.data.vai absent (expected — degraded mode)'); } else { - setStatus('test-site-vai', false, 'site.ext.vai should be absent in degrade mode but found: ' + JSON.stringify(siteVai)); + setStatus('test-site-vai', false, 'site.ext.data.vai should be absent in degrade mode but found: ' + JSON.stringify(siteVai)); } } else { if (siteVai && siteVai.iss && siteVai.dom) { - setStatus('test-site-vai', true, 'site.ext.vai: iss=' + siteVai.iss + ' dom=' + siteVai.dom); + setStatus('test-site-vai', true, 'site.ext.data.vai: iss=' + siteVai.iss + ' dom=' + siteVai.dom); } else { - setStatus('test-site-vai', false, 'site.ext.vai missing or incomplete: ' + JSON.stringify(siteVai)); + setStatus('test-site-vai', false, 'site.ext.data.vai missing or incomplete: ' + JSON.stringify(siteVai)); } } - // 2. ORTB2 user.ext.vai - var userVai = ortb2 && ortb2.user && ortb2.user.ext && ortb2.user.ext.vai; + // 2. ORTB2 user.ext.data.vai + var userVai = ortb2 && ortb2.user && ortb2.user.ext && ortb2.user.ext.data && ortb2.user.ext.data.vai; if (degradeMode) { if (!userVai) { - setStatus('test-user-vai', true, 'user.ext.vai absent (expected — degraded mode)'); + setStatus('test-user-vai', true, 'user.ext.data.vai absent (expected — degraded mode)'); } else { - setStatus('test-user-vai', false, 'user.ext.vai should be absent in degrade mode but found: ' + JSON.stringify(userVai)); + setStatus('test-user-vai', false, 'user.ext.data.vai should be absent in degrade mode but found: ' + JSON.stringify(userVai)); } } else { if (userVai && userVai.vat && userVai.act) { - setStatus('test-user-vai', true, 'user.ext.vai: vat=' + userVai.vat + ' act=' + userVai.act); + setStatus('test-user-vai', true, 'user.ext.data.vai: vat=' + userVai.vat + ' act=' + userVai.act); } else { - setStatus('test-user-vai', false, 'user.ext.vai missing or incomplete: ' + JSON.stringify(userVai)); + setStatus('test-user-vai', false, 'user.ext.data.vai missing or incomplete: ' + JSON.stringify(userVai)); } } @@ -133,17 +156,12 @@ setStatus('test-timing', false, 'auction took ' + elapsed + 'ms — exceeded 3000ms budget'); } - // 5. VAI available (window global OR localStorage cache) + // 5. VAI available (window global) var vaiSource = null; var vaiData = null; if (window.__PW_VAI__) { vaiSource = 'window.__PW_VAI__'; vaiData = window.__PW_VAI__; - } else { - try { - var raw = localStorage.getItem('__pw_vai__'); - if (raw) { vaiData = JSON.parse(raw); vaiSource = 'localStorage'; } - } catch (e) { /* ignore */ } } if (degradeMode) { if (!vaiData) { @@ -178,22 +196,9 @@ pbjs.setConfig({ debug: true, realTimeData: { - auctionDelay: 2000, dataProviders: [ { - name: 'paywalls', - waitForIt: true, - params: { - // Use 127.0.0.1 (not localhost) for real VAI host so that the - // Origin hostname differs from the worker bind address — this - // avoids wrangler dev's port-stacking CORS header mangling. - // Alt: 'https://dev.paywalls.net/pw/vai.js' (deployed worker) - scriptUrl: degradeMode - ? '/integrationExamples/gpt/nonexistent-vai.js' - : realMode - ? 'http://127.0.0.1:8080/pw/vai.js' - : '/integrationExamples/gpt/mock-vai.js' - } + name: 'paywalls' } ] } @@ -233,8 +238,8 @@

Paywalls RTD Provider — Integration Test

Test Results

⏳ Waiting for Prebid to load...
-
⏳ site.ext.vai
-
⏳ user.ext.vai
+
⏳ site.ext.data.vai
+
⏳ user.ext.data.vai
⏳ GAM targeting keys
⏳ Timing
⏳ Running...
diff --git a/modules/paywallsAnalyticsAdapter.js b/modules/paywallsAnalyticsAdapter.js index 2943cd3f09..adb18b91b4 100644 --- a/modules/paywallsAnalyticsAdapter.js +++ b/modules/paywallsAnalyticsAdapter.js @@ -9,6 +9,9 @@ * module already injects VAI into ORTB2 so SSPs and GAM can report * on it natively. * + * Publishers must load vai.js before Prebid.js initializes: + * + * * @module modules/paywallsAnalyticsAdapter * @see https://paywalls.net/docs/publishers/vai */ @@ -17,13 +20,10 @@ import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; import adapterManager from '../src/adapterManager.js'; import { EVENTS } from '../src/constants.js'; import { logInfo, logWarn } from '../src/utils.js'; -import { loadExternalScript } from '../src/adloader.js'; -import { MODULE_TYPE_ANALYTICS } from '../src/activities/modules.js'; const ADAPTER_CODE = 'paywalls'; const LOG_PREFIX = '[PaywallsAnalytics] '; -export const DEFAULT_SCRIPT_URL = '/pw/vai.js'; export const VAI_WINDOW_KEY = '__PW_VAI__'; const { AUCTION_END } = EVENTS; @@ -37,7 +37,6 @@ let emittedAuctions = Object.create(null); export let adapterConfig = { output: 'callback', // 'gtag' | 'dataLayer' | 'callback' - scriptUrl: DEFAULT_SCRIPT_URL, samplingRate: 1.0, callback: null, }; @@ -45,11 +44,8 @@ export let adapterConfig = { // Whether this page session is sampled in let sampledIn = true; -// Whether VAI script injection has been attempted -let vaiInjected = false; - // --------------------------------------------------------------------------- -// VAI loading +// VAI reading // --------------------------------------------------------------------------- /** @@ -67,29 +63,6 @@ export function getVaiClassification() { return null; } -/** - * Ensure VAI is available on the page. - * If window.__PW_VAI__ is not yet present, inject the script. - * @param {string} scriptUrl - */ -export function ensureVai(scriptUrl) { - const existing = getVaiClassification(); - if (existing) { - logInfo(LOG_PREFIX + 'VAI already present. vat=' + existing.vat); - return; - } - if (vaiInjected) { - return; - } - vaiInjected = true; - logInfo(LOG_PREFIX + 'injecting vai.js from ' + scriptUrl); - try { - loadExternalScript(scriptUrl, MODULE_TYPE_ANALYTICS, ADAPTER_CODE); - } catch (e) { - logWarn(LOG_PREFIX + 'failed to load vai.js:', e); - } -} - // --------------------------------------------------------------------------- // Metrics computation // --------------------------------------------------------------------------- @@ -97,8 +70,8 @@ export function ensureVai(scriptUrl) { /** * Build the KVP object emitted per auction. * Only contains VAI classification — vat and act. - * UNKNOWN values signal that VAI was not available (script failed to - * load, timed out, or returned an invalid response). + * UNKNOWN values signal that VAI was not available (publisher did not + * load vai.js, or it returned an invalid/expired response). * @returns {object} The KVP metrics object */ export function computeMetrics() { @@ -192,7 +165,7 @@ const paywallsAnalytics = Object.assign( paywallsAnalytics.originEnableAnalytics = paywallsAnalytics.enableAnalytics; /** - * Override enableAnalytics to parse config and inject VAI. + * Override enableAnalytics to parse config. * @param {object} config */ paywallsAnalytics.enableAnalytics = function (config) { @@ -200,7 +173,6 @@ paywallsAnalytics.enableAnalytics = function (config) { // Parse config adapterConfig.output = options.output || 'callback'; - adapterConfig.scriptUrl = options.scriptUrl || DEFAULT_SCRIPT_URL; const rawRate = typeof options.samplingRate === 'number' ? options.samplingRate : 1.0; adapterConfig.samplingRate = Math.max(0, Math.min(1, rawRate)); if (rawRate !== adapterConfig.samplingRate) { @@ -216,12 +188,6 @@ paywallsAnalytics.enableAnalytics = function (config) { // Reset state emittedAuctions = Object.create(null); - vaiInjected = false; - - // Ensure VAI is on the page only when this session is sampled in - if (sampledIn) { - ensureVai(adapterConfig.scriptUrl); - } // Call original enable (wires up event listeners) paywallsAnalytics.originEnableAnalytics(config); @@ -239,10 +205,8 @@ adapterManager.registerAnalyticsAdapter({ */ export function resetForTesting(overrides) { emittedAuctions = Object.create(null); - vaiInjected = false; sampledIn = true; adapterConfig.output = 'callback'; - adapterConfig.scriptUrl = DEFAULT_SCRIPT_URL; adapterConfig.samplingRate = 1.0; adapterConfig.callback = null; if (overrides) { diff --git a/modules/paywallsRtdProvider.js b/modules/paywallsRtdProvider.js index 158c8c90fb..fec6e0757f 100644 --- a/modules/paywallsRtdProvider.js +++ b/modules/paywallsRtdProvider.js @@ -4,7 +4,11 @@ * * VAI classifies page impressions by actor type (vat) and confidence tier * (act), producing a cryptographically signed assertion. The RTD submodule - * automates VAI loading, timing, and ORTB2 injection. + * reads the VAI payload from window.__PW_VAI__ (populated by the + * publisher's vai.js script tag) and injects it into ORTB2. + * + * Publishers must load vai.js before Prebid.js initializes: + * * * ORTB2 placement (canonical split): * site.ext.data.vai — { iss, dom } @@ -18,9 +22,6 @@ import { submodule } from '../src/hook.js'; import { mergeDeep, logInfo, logWarn } from '../src/utils.js'; -import { loadExternalScript } from '../src/adloader.js'; -import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; -import { getStorageManager } from '../src/storageManager.js'; /** * @typedef {import('../modules/rtdModule/index.js').RtdSubmodule} RtdSubmodule @@ -30,17 +31,7 @@ const LOG_PREFIX = '[PaywallsRtd] '; const MODULE_NAME = 'realTimeData'; export const SUBMODULE_NAME = 'paywalls'; -export const DEFAULT_SCRIPT_URL = '/pw/vai.js'; export const VAI_WINDOW_KEY = '__PW_VAI__'; -export const VAI_HOOK_KEY = '__PW_VAI_HOOK__'; -export const VAI_LS_KEY = '__pw_vai__'; - -const DEFAULT_WAIT_FOR_IT = 100; - -// Cached VAI payload from init (for early detection) -let cachedVai = null; - -export const storage = getStorageManager({ moduleType: MODULE_TYPE_RTD, moduleName: SUBMODULE_NAME }); // --------------------------------------------------------------------------- // Helpers @@ -59,29 +50,15 @@ function isValid(vai) { } /** - * Attempt to read a VAI payload from window or localStorage. + * Read the VAI payload from the window global. + * Returns the payload if valid and unexpired, null otherwise. * @returns {object|null} */ export function getVaiPayload() { - // 1. Check window global (set by vai.js