diff --git a/prebid-module/README.md b/prebid-module/README.md new file mode 100644 index 0000000..639a3f1 --- /dev/null +++ b/prebid-module/README.md @@ -0,0 +1,40 @@ +# Prebid module (vendor example) + +This folder contains an **example** Real-Time Data (RTD) submodule you can copy and adapt. It shows how Agentic Audiences signals can be read from browser storage and merged into the global ORTB2 fragment as `user.data`, in line with the OpenRTB community extension [**Agentic Audiences in OpenRTB**](https://github.com/InteractiveAdvertisingBureau/openrtb/blob/main/extensions/community_extensions/agentic-audiences.md). + +**Do not ship this copy as-is under the sample names.** Vendors are expected to **rename the module file**, align **submodule / build identifiers** with that name, and set **`gvlid`** to their own Global Vendor List ID. + +For the **canonical, maintained module** (build flags, publisher configuration, storage format, and behavior), use the implementation and documentation in the **[Prebid.js](https://github.com/prebid/Prebid.js)** repository—not this repo. + +## Initiative context + +- [IAB Tech Lab — Agentic Audiences](https://iabtechlab.com/standards/agentic-audiences/) +- [IABTechLab/agentic-audiences](https://github.com/IABTechLab/agentic-audiences) + +The upstream reference implementation was introduced in [Prebid.js #14626](https://github.com/prebid/Prebid.js/pull/14626). + +## What vendors should change + +When you implement your own module from this example: + +1. **Rename the module file** (e.g. `yourVendorAgenticRtd.js`) and update imports/paths everywhere it is referenced (including the matching test file name). +2. Set **`MODULE_NAME`** (and any exported submodule name) to a string that matches your RTD data provider name and your branding—keep it consistent with `modules/.submodules.json` and your docs. +3. Replace the **`gvlid`** placeholder with your vendor’s numeric GVL ID (see inline comment in the module source). +4. Register the build name in **`modules/.submodules.json`** (see `integration/submodules.json.snippet` for the pattern—your entry will use **your** module name, not the example’s). +5. Adjust **`DEFAULT_STORAGE_KEY`** / **`params.storageKey`** behavior if your integration uses different storage conventions. +6. Update or replace the unit test file so module paths and names match your fork. + +After that, follow **[Prebid.js contributing guidelines](https://github.com/prebid/Prebid.js/blob/master/CONTRIBUTING.md)** if you intend to open a pull request against Prebid.js, and point publishers to **Prebid.org / the Prebid.js repo** for how to build and configure the bundle. + +## Layout (this example) + +| Path | Role | +| --- | --- | +| `modules/agenticAudienceAdapter.js` | Example RTD submodule—**rename and edit** for your vendor module | +| `test/spec/modules/agenticAudienceAdapter_spec.js` | Example tests—**rename and align** with your module | +| `integration/submodules.json.snippet` | Illustrative `.submodules.json` entry pattern | +| `examples/publisher-config.example.js` | Illustrative only; real publisher setup lives with Prebid docs | + +## Where usage is documented + +- **Build, `setConfig`, and operational details:** [prebid/Prebid.js](https://github.com/prebid/Prebid.js) and [docs.prebid.org](https://docs.prebid.org/). diff --git a/prebid-module/examples/publisher-config.example.js b/prebid-module/examples/publisher-config.example.js new file mode 100644 index 0000000..142e7e1 --- /dev/null +++ b/prebid-module/examples/publisher-config.example.js @@ -0,0 +1,26 @@ +/** + * Example Prebid.js publisher configuration for the Agentic Audiences RTD submodule. + * Copy into your site’s Prebid bootstrap. Replace the GVL placeholder with your + * vendor’s numeric Global Vendor List ID (see README.md in this folder). + */ + +pbjs.setConfig({ + consentManagement: { + // ... your CMP / GDPR config as required + }, + gvlMapping: { + // Key must match the RTD data provider name: "agenticAudience" + agenticAudience: 0, // TODO: replace 0 with your vendor’s numeric GVL ID (not a string) + }, + realTimeData: { + dataProviders: [ + { + name: 'agenticAudience', + params: { + // Optional: use a dedicated storage key for your integration + // storageKey: '_my_vendor_agentic_audience_', + }, + }, + ], + }, +}); diff --git a/prebid-module/integration/submodules.json.snippet b/prebid-module/integration/submodules.json.snippet new file mode 100644 index 0000000..3533e39 --- /dev/null +++ b/prebid-module/integration/submodules.json.snippet @@ -0,0 +1 @@ +"agenticAudienceAdapter", diff --git a/prebid-module/modules/agenticAudienceAdapter.js b/prebid-module/modules/agenticAudienceAdapter.js new file mode 100644 index 0000000..e67bee3 --- /dev/null +++ b/prebid-module/modules/agenticAudienceAdapter.js @@ -0,0 +1,142 @@ +/** + * Agentic Audience Adapter – injects Agentic Audiences (vector-based) signals into the OpenRTB request. + * Conforms to the OpenRTB community extension: + * {@link https://github.com/InteractiveAdvertisingBureau/openrtb/blob/main/extensions/community_extensions/agentic-audiences.md Agentic Audiences in OpenRTB} + * + * Context: {@link https://github.com/IABTechLab/agentic-audiences IABTechLab Agentic Audiences} + * + * The {@link module:modules/realTimeData} module is required + * + * Injects one OpenRTB `Data` object into `user.data` (`name` = submodule id, `segment[]` from storage). + * Each segment has optional `id`/`name` and `ext.aa` with `ver`, `vector`, `dimension`, `model`, `type`. + * Storage is read from the default key (see `DEFAULT_STORAGE_KEY` export) unless `params.storageKey` is set. + * + * @module modules/agenticAudienceAdapter + * @requires module:modules/realTimeData + */ + +import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; +import { submodule } from '../src/hook.js'; +import { getStorageManager } from '../src/storageManager.js'; +import { logInfo, mergeDeep } from '../src/utils.js'; + +/** + * @typedef {import('./rtdModule/index.js').RtdSubmodule} RtdSubmodule + */ + +const REAL_TIME_MODULE = 'realTimeData'; +const MODULE_NAME = 'agenticAudience'; + +/** @type {string} Default localStorage / cookie key when `params.storageKey` is omitted. */ +export const DEFAULT_STORAGE_KEY = '_agentic_audience_'; + +export const storage = getStorageManager({ + moduleType: MODULE_TYPE_RTD, + moduleName: MODULE_NAME, +}); + +function dataFromLocalStorage(key) { + return storage.localStorageIsEnabled() ? storage.getDataFromLocalStorage(key) : null; +} + +function dataFromCookie(key) { + return storage.cookiesAreEnabled() ? storage.getCookie(key) : null; +} + +/** + * Map a stored entry to an OpenRTB Segment (Agentic Audiences): id, name, ext.aa.{ver, vector, dimension, model, type} + * Assumes storage matches the intended shape; fields are copied without validation or coercion. + * @param {Object} entry - Raw entry from storage `entries` array + * @returns {Object|null} + */ +export function mapEntryToOpenRtbSegment(entry) { + if (entry == null || typeof entry !== 'object') return null; + + return { + id: entry.id, + name: entry.name, + ext: { + aa: { + ver: entry.ver, + vector: entry.vector, + dimension: entry.dimension, + model: entry.model, + type: entry.type + } + } + }; +} + +function init(config, userConsent) { + return true; +} + +/** + * @param {Object} reqBidsConfigObj + * @param {function} callback + * @param {Object} config + * @param {Object} userConsent + */ +function getBidRequestData(reqBidsConfigObj, callback, config, userConsent) { + const customKey = config?.params?.storageKey; + const storageKey = + typeof customKey === 'string' && customKey.length > 0 ? customKey : DEFAULT_STORAGE_KEY; + + const segments = getSegmentsForStorageKey(storageKey); + + if (!segments || segments.length === 0) { + callback(); + return; + } + + const updated = { + user: { + data: [ + { + name: MODULE_NAME, + segment: segments + } + ] + } + }; + + mergeDeep(reqBidsConfigObj.ortb2Fragments.global, updated); + callback(); +} + +function tryParse(data) { + try { + return JSON.parse(atob(data)); + } catch (error) { + logInfo(error); + return null; + } +} + +function getSegmentsForStorageKey(key) { + const storedData = dataFromLocalStorage(key) || dataFromCookie(key); + + if (!storedData || typeof storedData !== 'string') { + return []; + } + + const parsed = tryParse(storedData); + + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return []; + } + + return parsed.entries + .map(entry => mapEntryToOpenRtbSegment(entry)) + .filter(seg => seg != null); +} + +/** @type {RtdSubmodule} */ +export const agenticAudienceAdapterSubmodule = { + name: MODULE_NAME, + gvlid: 0, // change this to your gvlid + init, + getBidRequestData +}; + +submodule(REAL_TIME_MODULE, agenticAudienceAdapterSubmodule); diff --git a/prebid-module/test/spec/modules/agenticAudienceAdapter_spec.js b/prebid-module/test/spec/modules/agenticAudienceAdapter_spec.js new file mode 100644 index 0000000..0e06293 --- /dev/null +++ b/prebid-module/test/spec/modules/agenticAudienceAdapter_spec.js @@ -0,0 +1,214 @@ +import { + agenticAudienceAdapterSubmodule, + DEFAULT_STORAGE_KEY, + mapEntryToOpenRtbSegment, + storage +} from 'modules/agenticAudienceAdapter.js'; + +/** Test fixture: OpenRTB Float32 LE base64 (module expects pre-encoded storage only). */ +function vectorBase64Fixture(arr) { + const buffer = new ArrayBuffer(arr.length * 4); + const view = new DataView(buffer); + arr.forEach((x, i) => view.setFloat32(i * 4, x, true)); + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); +} + +describe('agenticAudienceAdapter', function () { + let sandbox; + let reqBidsConfigObj; + let storageGetLocalStub; + let storageGetCookieStub; + let storageLocalEnabledStub; + let storageCookiesEnabledStub; + + const validEntry = { + ver: '1.0', + vector: vectorBase64Fixture([0.1, -0.2, 0.3]), + model: 'sbert-mini-ctx-001', + dimension: 3, + type: [1, 2] + }; + + const encodeData = (obj) => btoa(JSON.stringify(obj)); + + beforeEach(function () { + sandbox = sinon.createSandbox(); + reqBidsConfigObj = { ortb2Fragments: { global: {} } }; + storageGetLocalStub = sandbox.stub(storage, 'getDataFromLocalStorage'); + storageGetCookieStub = sandbox.stub(storage, 'getCookie'); + storageLocalEnabledStub = sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + storageCookiesEnabledStub = sandbox.stub(storage, 'cookiesAreEnabled').returns(true); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('mapEntryToOpenRtbSegment', function () { + it('maps stored Base64 vector to Segment unchanged', function () { + const seg = mapEntryToOpenRtbSegment(validEntry); + expect(seg.id).to.be.undefined; + expect(seg.name).to.be.undefined; + expect(seg.ext.aa.ver).to.equal('1.0'); + expect(seg.ext.aa.vector).to.equal(validEntry.vector); + expect(seg.ext.aa.dimension).to.equal(3); + expect(seg.ext.aa.model).to.equal('sbert-mini-ctx-001'); + expect(seg.ext.aa.type).to.deep.equal([1, 2]); + }); + + it('passes vector through without coercion (e.g. array storage)', function () { + const arr = [0.1, 0.2, 0.3]; + const seg = mapEntryToOpenRtbSegment({ ...validEntry, vector: arr }); + expect(seg.ext.aa.vector).to.equal(arr); + }); + + it('passes type through without normalizing number to array', function () { + const seg = mapEntryToOpenRtbSegment({ ...validEntry, type: 1 }); + expect(seg.ext.aa.type).to.equal(1); + }); + + it('uses custom id and name when provided', function () { + const seg = mapEntryToOpenRtbSegment({ + ...validEntry, + id: 'seg-1', + name: 'identity-contextual' + }); + expect(seg.id).to.equal('seg-1'); + expect(seg.name).to.equal('identity-contextual'); + }); + + it('returns null only for non-object entry', function () { + expect(mapEntryToOpenRtbSegment(null)).to.equal(null); + expect(mapEntryToOpenRtbSegment(undefined)).to.equal(null); + }); + + it('maps empty object to segment with id, name, and ext fields undefined', function () { + const seg = mapEntryToOpenRtbSegment({}); + expect(seg.id).to.be.undefined; + expect(seg.name).to.be.undefined; + expect(seg.ext).to.deep.equal({ + aa: { + ver: undefined, + vector: undefined, + dimension: undefined, + model: undefined, + type: undefined + } + }); + }); + }); + + describe('init', function () { + it('returns true regardless of params', function () { + expect(agenticAudienceAdapterSubmodule.init({})).to.equal(true); + expect(agenticAudienceAdapterSubmodule.init({ params: { storageKey: '_custom_' } })).to.equal(true); + }); + }); + + describe('getBidRequestData', function () { + it('uses default storage key when params omitted', function () { + const config = {}; + const callback = sinon.spy(); + storageGetLocalStub.withArgs(DEFAULT_STORAGE_KEY).returns(encodeData({ entries: [validEntry] })); + storageGetCookieStub.returns(null); + + agenticAudienceAdapterSubmodule.getBidRequestData(reqBidsConfigObj, callback, config); + + expect(callback.calledOnce).to.be.true; + expect(reqBidsConfigObj.ortb2Fragments.global.user.data).to.have.length(1); + expect(reqBidsConfigObj.ortb2Fragments.global.user.data[0].name).to.equal('agenticAudience'); + expect(reqBidsConfigObj.ortb2Fragments.global.user.data[0].segment).to.deep.equal([ + mapEntryToOpenRtbSegment(validEntry) + ]); + }); + + it('uses params.storageKey when provided', function () { + const config = { params: { storageKey: '_custom_agentic_' } }; + const callback = sinon.spy(); + storageGetLocalStub.withArgs('_custom_agentic_').returns(encodeData({ entries: [validEntry] })); + storageGetCookieStub.returns(null); + + agenticAudienceAdapterSubmodule.getBidRequestData(reqBidsConfigObj, callback, config); + + expect(reqBidsConfigObj.ortb2Fragments.global.user.data[0].name).to.equal('agenticAudience'); + expect(reqBidsConfigObj.ortb2Fragments.global.user.data[0].segment).to.deep.equal([ + mapEntryToOpenRtbSegment(validEntry) + ]); + }); + + it('falls back to default key when storageKey is empty string', function () { + const config = { params: { storageKey: '' } }; + const callback = sinon.spy(); + storageGetLocalStub.withArgs(DEFAULT_STORAGE_KEY).returns(encodeData({ entries: [validEntry] })); + storageGetCookieStub.returns(null); + + agenticAudienceAdapterSubmodule.getBidRequestData(reqBidsConfigObj, callback, config); + + expect(reqBidsConfigObj.ortb2Fragments.global.user.data[0].segment).to.deep.equal([ + mapEntryToOpenRtbSegment(validEntry) + ]); + }); + + it('calls callback and does not inject when storage has no data', function () { + const config = {}; + const callback = sinon.spy(); + storageGetLocalStub.withArgs(DEFAULT_STORAGE_KEY).returns(null); + storageGetCookieStub.returns(null); + + agenticAudienceAdapterSubmodule.getBidRequestData(reqBidsConfigObj, callback, config); + + expect(callback.calledOnce).to.be.true; + expect(reqBidsConfigObj.ortb2Fragments.global.user).to.be.undefined; + }); + + it('does not inject when stored data has empty entries array', function () { + const config = {}; + const callback = sinon.spy(); + storageGetLocalStub.withArgs(DEFAULT_STORAGE_KEY).returns(encodeData({ entries: [] })); + storageGetCookieStub.returns(null); + + agenticAudienceAdapterSubmodule.getBidRequestData(reqBidsConfigObj, callback, config); + + expect(callback.calledOnce).to.be.true; + expect(reqBidsConfigObj.ortb2Fragments.global.user).to.be.undefined; + }); + + it('reads from cookie when localStorage returns null', function () { + const config = {}; + const callback = sinon.spy(); + storageGetLocalStub.returns(null); + storageGetCookieStub.withArgs(DEFAULT_STORAGE_KEY).returns(encodeData({ entries: [validEntry] })); + + agenticAudienceAdapterSubmodule.getBidRequestData(reqBidsConfigObj, callback, config); + + expect(reqBidsConfigObj.ortb2Fragments.global.user.data[0].segment).to.deep.equal([ + mapEntryToOpenRtbSegment(validEntry) + ]); + }); + }); + + describe('generates valid OpenRTB user object (Agentic Audiences extension)', function () { + it('produces valid structure under user.data[0]', function () { + const config = {}; + const callback = sinon.spy(); + storageGetLocalStub.withArgs(DEFAULT_STORAGE_KEY).returns(encodeData({ entries: [validEntry] })); + storageGetCookieStub.returns(null); + + agenticAudienceAdapterSubmodule.getBidRequestData(reqBidsConfigObj, callback, config); + + expect(reqBidsConfigObj.ortb2Fragments.global.user.data).to.have.length(1); + const dataObj = reqBidsConfigObj.ortb2Fragments.global.user.data[0]; + expect(dataObj).to.have.keys('name', 'segment'); + expect(dataObj.name).to.equal('agenticAudience'); + const seg = dataObj.segment[0]; + expect(seg).to.have.keys('id', 'name', 'ext'); + expect(seg.ext).to.have.keys('aa'); + expect(seg.ext.aa).to.have.keys('ver', 'vector', 'dimension', 'model', 'type'); + expect(seg.ext.aa.vector).to.equal(validEntry.vector); + expect(seg).to.deep.equal(mapEntryToOpenRtbSegment(validEntry)); + }); + }); +});