diff --git a/modules/.submodules.json b/modules/.submodules.json index c0e972f3c5..339cb3aa84 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -49,6 +49,7 @@ "pubProvidedIdSystem", "publinkIdSystem", "pubmaticIdSystem", + "rediadsIdSystem", "rewardedInterestIdSystem", "sharedIdSystem", "startioIdSystem", @@ -141,4 +142,4 @@ "adplayerproVideoProvider" ] } -} \ No newline at end of file +} diff --git a/modules/rediadsIdSystem.js b/modules/rediadsIdSystem.js new file mode 100644 index 0000000000..988372dc98 --- /dev/null +++ b/modules/rediadsIdSystem.js @@ -0,0 +1,180 @@ +/** + * This module adds Rediads ID support to the User ID module + * The {@link module:modules/userId} module is required. + * @module modules/rediadsIdSystem + * @requires module:modules/userId + */ + +import { submodule } from '../src/hook.js'; +import { VENDORLESS_GVLID } from '../src/consentHandler.js'; +import { cyrb53Hash, generateUUID, isPlainObject, isStr } from '../src/utils.js'; + +/** + * @typedef {import('../modules/userId/index.js').Submodule} Submodule + * @typedef {import('../modules/userId/index.js').SubmoduleConfig} SubmoduleConfig + * @typedef {import('../modules/userId/index.js').ConsentData} ConsentData + * @typedef {import('../modules/userId/index.js').IdResponse} IdResponse + */ + +const MODULE_NAME = 'rediadsId'; +const DEFAULT_SOURCE = 'rediads.com'; +const DEFAULT_ATYPE = 1; +const DEFAULT_EXPIRES_DAYS = 30; +const DEFAULT_REFRESH_SECONDS = 3600; + +function normalizeStoredId(storedId) { + if (isPlainObject(storedId)) { + return storedId; + } + + if (isStr(storedId)) { + try { + const parsed = JSON.parse(storedId); + if (isPlainObject(parsed)) { + return parsed; + } + } catch (e) {} + } +} + +function getConsentHash(consentData = {}) { + return `hash_${cyrb53Hash(JSON.stringify({ + gdpr: consentData.gdpr?.consentString || null, + gpp: consentData.gpp?.gppString || null, + usp: consentData.usp || null, + coppa: consentData.coppa === true + }))}`; +} + +function hasTcfPurpose1Consent(gdprConsent) { + return gdprConsent?.vendorData?.purpose?.consents?.[1] === true || + gdprConsent?.vendorData?.purposeConsents?.[1] === true; +} + +function canWriteStorage(consentData = {}) { + if (consentData.coppa === true) { + return false; + } + + const gdprConsent = consentData.gdpr; + if (gdprConsent?.gdprApplies === true) { + return isStr(gdprConsent?.consentString) && + gdprConsent.consentString.length > 0 && + hasTcfPurpose1Consent(gdprConsent); + } + + return true; +} + +function canShareId(consentData = {}) { + if (consentData.coppa === true) { + return false; + } + + if (isStr(consentData.usp) && consentData.usp.charAt(2) === 'Y') { + return false; + } + + return true; +} + +function ensureStorageDefaults(config) { + if (isPlainObject(config?.storage)) { + if (config.storage.expires == null) { + config.storage.expires = DEFAULT_EXPIRES_DAYS; + } + if (config.storage.refreshInSeconds == null) { + config.storage.refreshInSeconds = DEFAULT_REFRESH_SECONDS; + } + } +} + +function buildStoredId(config, consentData, existingId) { + const params = config?.params || {}; + const source = params.source || DEFAULT_SOURCE; + const expiresDays = config?.storage?.expires ?? DEFAULT_EXPIRES_DAYS; + const refreshInSeconds = config?.storage?.refreshInSeconds ?? DEFAULT_REFRESH_SECONDS; + const now = Date.now(); + + return { + id: existingId || `ruid_${generateUUID()}`, + source, + atype: DEFAULT_ATYPE, + canShare: canShareId(consentData), + consentHash: getConsentHash(consentData), + refreshAfter: now + (refreshInSeconds * 1000), + expiresAt: now + (expiresDays * 24 * 60 * 60 * 1000) + }; +} + +/** @type {Submodule} */ +export const rediadsIdSubmodule = { + name: MODULE_NAME, + gvlid: VENDORLESS_GVLID, + + decode(value) { + const storedId = normalizeStoredId(value); + if (!isStr(storedId?.id) || storedId.id.length === 0) { + return undefined; + } + + return { + rediadsId: { + uid: storedId.id, + source: storedId.source || DEFAULT_SOURCE, + atype: storedId.atype || DEFAULT_ATYPE, + ext: { + canShare: storedId.canShare !== false, + consentHash: storedId.consentHash, + refreshAfter: storedId.refreshAfter + } + } + }; + }, + + getId(config, consentData, storedId) { + ensureStorageDefaults(config); + + if (!canWriteStorage(consentData)) { + return undefined; + } + + const normalized = normalizeStoredId(storedId); + return { + id: buildStoredId(config, consentData, normalized?.id) + }; + }, + + extendId(config, consentData, storedId) { + ensureStorageDefaults(config); + + if (!canWriteStorage(consentData)) { + return undefined; + } + + const normalized = normalizeStoredId(storedId); + if (!isStr(normalized?.id) || normalized.id.length === 0) { + return this.getId(config, consentData, storedId); + } + + return { + id: buildStoredId(config, consentData, normalized.id) + }; + }, + + eids: { + rediadsId(values) { + return values + .filter((value) => isStr(value?.uid) && value.ext?.canShare !== false) + .map((value) => ({ + source: value.source || DEFAULT_SOURCE, + uids: [{ + id: value.uid, + atype: value.atype || DEFAULT_ATYPE + }] + })); + } + } +}; + +submodule('userId', rediadsIdSubmodule); diff --git a/modules/rediadsIdSystem.md b/modules/rediadsIdSystem.md new file mode 100644 index 0000000000..72021664b0 --- /dev/null +++ b/modules/rediadsIdSystem.md @@ -0,0 +1,52 @@ +## Rediads User ID Submodule + +The Rediads User ID submodule generates a first-party identifier in the browser, stores it through Prebid's `userId` framework, and exposes it through `bidRequest.userId` and `userIdAsEids`. + +The module is vendorless for TCF enforcement, so Prebid applies purpose-level storage checks without requiring a separate vendor consent entry. + +### Build the Module + +```bash +gulp build --modules=userId,rediadsIdSystem +``` + +### Example Configuration + +```javascript +pbjs.setConfig({ + userSync: { + userIds: [{ + name: 'rediadsId', + params: { + source: 'rediads.com' + }, + storage: { + type: 'html5', + name: 'rediads_id', + expires: 30, + refreshInSeconds: 3600 + } + }] + } +}); +``` + +### Parameters + +| Param under `userSync.userIds[]` | Scope | Type | Description | Example | +| --- | --- | --- | --- | --- | +| `name` | Required | String | Name of the module. | `'rediadsId'` | +| `params` | Optional | Object | Rediads-specific configuration. | | +| `params.source` | Optional | String | EID `source` value to emit. Defaults to `'rediads.com'`. | `'rediads.com'` | +| `storage.type` | Recommended | String | Prebid-managed storage type. | `'html5'` | +| `storage.name` | Recommended | String | Storage key used by Prebid. | `'rediads_id'` | +| `storage.expires` | Optional | Number | Days before the cached ID expires. Defaults to `30`. | `30` | +| `storage.refreshInSeconds` | Optional | Number | Seconds before the cached ID is refreshed. Defaults to `3600`. | `3600` | + +### Behavior Notes + +- `getId()` generates a `ruid_` value on first use. +- `extendId()` preserves the existing Rediads ID and refreshes metadata. +- `decode()` exposes the ID as `bidRequest.userId.rediadsId`. +- EIDs are suppressed when US Privacy or GPP opt-out signals indicate sharing should be blocked. +- The module returns `undefined` when COPPA applies or when GDPR applies without TCF Purpose 1 consent. diff --git a/modules/userId/eids.md b/modules/userId/eids.md index b2fb808baf..292a5fd0de 100644 --- a/modules/userId/eids.md +++ b/modules/userId/eids.md @@ -204,6 +204,14 @@ userIdAsEids = [ }] }, + { + source: 'rediads.com', + uids: [{ + id: 'ruid_7b9c1d3f-1e2b-4e7b-9e5a-acde12345678', + atype: 1 + }] + }, + { source: 'britepool.com', uids: [{ diff --git a/modules/userId/userId.md b/modules/userId/userId.md index ca2b3fb525..02d9c0fec2 100644 --- a/modules/userId/userId.md +++ b/modules/userId/userId.md @@ -158,6 +158,18 @@ pbjs.setConfig({ { name: "mygaruId" }, + { + name: "rediadsId", + params: { + source: "rediads.com" + }, + storage: { + type: "html5", + name: "rediads_id", + expires: 30, + refreshInSeconds: 3600 + } + }, { name: "startioId" } diff --git a/test/spec/modules/rediadsIdSystem_spec.js b/test/spec/modules/rediadsIdSystem_spec.js new file mode 100644 index 0000000000..a8314eca34 --- /dev/null +++ b/test/spec/modules/rediadsIdSystem_spec.js @@ -0,0 +1,200 @@ +import * as utils from '../../../src/utils.js'; +import { createEidsArray } from '../../../modules/userId/eids.js'; +import { rediadsIdSubmodule } from 'modules/rediadsIdSystem.js'; + +describe('Rediads ID System', function () { + let sandbox; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + sandbox.stub(utils, 'generateUUID').returns('11111111-2222-4333-8444-555555555555'); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('module registration', function () { + it('should register the submodule', function () { + expect(rediadsIdSubmodule.name).to.equal('rediadsId'); + }); + + it('should provide function-based eids mapping', function () { + expect(rediadsIdSubmodule.eids).to.have.property('rediadsId'); + expect(rediadsIdSubmodule.eids.rediadsId).to.be.a('function'); + }); + }); + + describe('decode', function () { + it('should return undefined for invalid values', function () { + expect(rediadsIdSubmodule.decode()).to.be.undefined; + expect(rediadsIdSubmodule.decode('invalid-json')).to.be.undefined; + expect(rediadsIdSubmodule.decode({})).to.be.undefined; + }); + + it('should decode a stored object', function () { + const result = rediadsIdSubmodule.decode({ + id: 'ruid_test', + source: 'rediads.example', + atype: 1, + canShare: true, + consentHash: 'hash_123', + refreshAfter: 12345 + }); + + expect(result).to.deep.equal({ + rediadsId: { + uid: 'ruid_test', + source: 'rediads.example', + atype: 1, + ext: { + canShare: true, + consentHash: 'hash_123', + refreshAfter: 12345 + } + } + }); + }); + }); + + describe('getId', function () { + it('should generate a new id when consent allows storage', function () { + const config = { + params: {}, + storage: { + type: 'html5', + name: 'rediads_id' + } + }; + + const result = rediadsIdSubmodule.getId(config, {}); + expect(result.id.id).to.equal('ruid_11111111-2222-4333-8444-555555555555'); + expect(result.id.source).to.equal('rediads.com'); + expect(result.id.canShare).to.equal(true); + expect(config.storage.expires).to.equal(30); + expect(config.storage.refreshInSeconds).to.equal(3600); + }); + + it('should reuse the stored id when refreshing', function () { + const result = rediadsIdSubmodule.getId({ + params: { + source: 'custom.rediads.com' + }, + storage: {} + }, {}, { + id: 'ruid_existing' + }); + + expect(result.id.id).to.equal('ruid_existing'); + expect(result.id.source).to.equal('custom.rediads.com'); + }); + + it('should return undefined when gdpr applies without purpose 1 consent', function () { + const result = rediadsIdSubmodule.getId({}, { + gdpr: { + gdprApplies: true, + consentString: 'CONSENT', + vendorData: { + purpose: { + consents: { + 1: false + } + } + } + } + }); + + expect(result).to.be.undefined; + }); + + it('should not enforce gdpr gating when gdprApplies is false', function () { + const result = rediadsIdSubmodule.getId({}, { + gdpr: { + gdprApplies: false, + consentString: 'CONSENT', + vendorData: { + purpose: { + consents: { + 1: false + } + } + } + } + }); + + expect(result.id.id).to.equal('ruid_11111111-2222-4333-8444-555555555555'); + }); + + it('should preserve the id but disable eids when usp opts out', function () { + const result = rediadsIdSubmodule.getId({}, { + usp: '1YYN' + }); + + expect(result.id.id).to.equal('ruid_11111111-2222-4333-8444-555555555555'); + expect(result.id.canShare).to.equal(false); + }); + }); + + describe('extendId', function () { + it('should reuse an existing id', function () { + const result = rediadsIdSubmodule.extendId({}, {}, { + id: 'ruid_existing' + }); + + expect(result.id.id).to.equal('ruid_existing'); + }); + }); + + describe('eid translation', function () { + it('should emit an eid when sharing is allowed', function () { + const eids = createEidsArray({ + rediadsId: { + uid: 'ruid_test', + source: 'rediads.com', + atype: 1, + ext: { + canShare: true + } + } + }, new Map(Object.entries(rediadsIdSubmodule.eids))); + + expect(eids).to.eql([{ + source: 'rediads.com', + uids: [{ + id: 'ruid_test', + atype: 1 + }] + }]); + }); + + it('should suppress eids when sharing is blocked', function () { + const eids = createEidsArray({ + rediadsId: { + uid: 'ruid_test', + source: 'rediads.com', + atype: 1, + ext: { + canShare: false + } + } + }, new Map(Object.entries(rediadsIdSubmodule.eids))); + + expect(eids).to.eql([]); + }); + + it('should not suppress eids based on gpp applicable sections alone', function () { + const result = rediadsIdSubmodule.getId({}, { + gpp: { + gppString: 'DBABMA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA', + applicableSections: [7] + } + }); + + const decoded = rediadsIdSubmodule.decode(result.id); + const eids = createEidsArray(decoded, new Map(Object.entries(rediadsIdSubmodule.eids))); + + expect(eids).to.have.length(1); + expect(eids[0].source).to.equal('rediads.com'); + }); + }); +});