diff --git a/modules/.submodules.json b/modules/.submodules.json index 6dac11bf0ed..d13329f3587 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -35,6 +35,7 @@ "netIdSystem", "novatiqIdSystem", "oneKeyIdSystem", + "openPairIdSystem", "operaadsIdSystem", "permutiveIdentityManagerIdSystem", "pubProvidedIdSystem", diff --git a/modules/openPairIdSystem.js b/modules/openPairIdSystem.js new file mode 100644 index 00000000000..50665d310eb --- /dev/null +++ b/modules/openPairIdSystem.js @@ -0,0 +1,138 @@ +/** + * This module adds Open PAIR Id to the User ID module + * The {@link module:modules/userId} module is required + * @module modules/openPairIdSystem + * @requires module:modules/userId + */ + +import {submodule} from '../src/hook.js'; +import {getStorageManager} from '../src/storageManager.js' +import {logInfo} from '../src/utils.js'; +import {MODULE_TYPE_UID} from '../src/activities/modules.js'; +import {VENDORLESS_GVLID} from '../src/consentHandler.js'; + +/** + * @typedef {import('../modules/userId/index.js').Submodule} Submodule + */ + +const MODULE_NAME = 'openPairId'; +const DEFAULT_PUBLISHER_ID_KEY = 'pairId'; + +const DEFAULT_STORAGE_PUBLISHER_ID_KEYS = { + liveramp: '_lr_pairId' +}; + +export const storage = getStorageManager({moduleType: MODULE_TYPE_UID, moduleName: MODULE_NAME}); + +function publisherIdFromLocalStorage(key) { + return storage.localStorageIsEnabled() ? storage.getDataFromLocalStorage(key) : null; +} + +function publisherIdFromCookie(key) { + return storage.cookiesAreEnabled() ? storage.getCookie(key) : null; +} + +/** @type {Submodule} */ +export const openPairIdSubmodule = { + /** + * used to link submodule with config + * @type {string} + */ + name: MODULE_NAME, + /** + * used to specify vendor id + * @type {number} + */ + gvlid: VENDORLESS_GVLID, + /** + * decode the stored id value for passing to bid requests + * @function + * @param { string | undefined } value + * @returns {{pairId:string} | undefined } + */ + decode(value) { + return value && Array.isArray(value) ? {'openPairId': value} : undefined; + }, + /** + * Performs action to obtain ID and return a value in the callback's response argument. + * @function getId + * @param {Object} config - The configuration object. + * @param {Object} config.params - The parameters from the configuration. + * @returns {{id: string[] | undefined}} The obtained IDs or undefined if no IDs are found. + */ + getId(config) { + const publisherIdsString = publisherIdFromLocalStorage(DEFAULT_PUBLISHER_ID_KEY) || publisherIdFromCookie(DEFAULT_PUBLISHER_ID_KEY); + let ids = [] + + if (publisherIdsString && typeof publisherIdsString == 'string') { + try { + ids = ids.concat(JSON.parse(atob(publisherIdsString))); + } catch (error) { + logInfo(error) + } + } + + const configParams = (config && config.params) ? config.params : {}; + const cleanRooms = Object.keys(configParams); + + for (let i = 0; i < cleanRooms.length; i++) { + const cleanRoom = cleanRooms[i]; + const cleanRoomParams = configParams[cleanRoom]; + + const cleanRoomStorageLocation = cleanRoomParams.storageKey || DEFAULT_STORAGE_PUBLISHER_ID_KEYS[cleanRoom]; + const cleanRoomValue = publisherIdFromLocalStorage(cleanRoomStorageLocation) || publisherIdFromCookie(cleanRoomStorageLocation); + + if (cleanRoomValue) { + try { + const parsedValue = atob(cleanRoomValue); + + if (parsedValue) { + const obj = JSON.parse(parsedValue); + + if (obj && typeof obj === 'object' && obj.envelope) { + ids = ids.concat(obj.envelope); + } else { + logInfo('Open Pair ID: Parsed object is not valid or does not contain envelope'); + } + } else { + logInfo('Open Pair ID: Decoded value is empty'); + } + } catch (error) { + logInfo('Open Pair ID: Error parsing JSON: ', error); + } + } else { + logInfo('Open Pair ID: data clean room value for pairId from storage is empty or null'); + } + } + + if (ids.length == 0) { + logInfo('Open Pair ID: no ids found') + return undefined; + } + + return {'id': ids}; + }, + eids: { + openPairId: function(values, config = {}) { + const inserter = config.inserter; + const matcher = config.matcher; + + return [ + { + source: 'pair-protocol.com', + mm: 3, + inserter: inserter, + matcher: matcher, + uids: values.map(function(value) { + return { + id: value, + atype: 3 + } + }) + } + ]; + } + }, +}; + +submodule('userId', openPairIdSubmodule); diff --git a/test/spec/modules/openPairIdSystem_spec.js b/test/spec/modules/openPairIdSystem_spec.js new file mode 100644 index 00000000000..57c1b6ead87 --- /dev/null +++ b/test/spec/modules/openPairIdSystem_spec.js @@ -0,0 +1,173 @@ +import { storage, openPairIdSubmodule } from 'modules/openPairIdSystem.js'; +import * as utils from 'src/utils.js'; + +import { + attachIdSystem, + coreStorage, + getConsentHash, + init, + startAuctionHook, + setSubmoduleRegistry +} from '../../../modules/userId/index.js'; + +import {createEidsArray, getEids} from '../../../modules/userId/eids.js'; + +describe('openPairId', function () { + let sandbox; + let logInfoStub; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + logInfoStub = sandbox.stub(utils, 'logInfo'); + }); + afterEach(() => { + sandbox.restore(); + }); + + it('should read publisher id from specified clean room if configured with storageKey', function() { + let publisherIds = ['test-pair-id1', 'test-pair-id2', 'test-pair-id3']; + sandbox.stub(storage, 'getDataFromLocalStorage').withArgs('habu_pairId_custom').returns(btoa(JSON.stringify({'envelope': publisherIds}))); + + let id = openPairIdSubmodule.getId({ + params: { + habu: { + storageKey: 'habu_pairId_custom' + } + }}) + + expect(id).to.be.deep.equal({id: publisherIds}); + }); + + it('should read publisher id from liveramp with default storageKey and additional clean room with configured storageKey', function() { + let getDataStub = sandbox.stub(storage, 'getDataFromLocalStorage'); + let liveRampPublisherIds = ['lr-test-pair-id1', 'lr-test-pair-id2', 'lr-test-pair-id3']; + getDataStub.withArgs('_lr_pairId').returns(btoa(JSON.stringify({'envelope': liveRampPublisherIds}))); + + let habuPublisherIds = ['habu-test-pair-id1', 'habu-test-pair-id2', 'habu-test-pair-id3']; + getDataStub.withArgs('habu_pairId_custom').returns(btoa(JSON.stringify({'envelope': habuPublisherIds}))); + + let id = openPairIdSubmodule.getId({ + params: { + habu: { + storageKey: 'habu_pairId_custom' + }, + liveramp: {} + }}) + + expect(id).to.be.deep.equal({id: habuPublisherIds.concat(liveRampPublisherIds)}); + }); + + it('should log an error if no ID is found when getId', function() { + openPairIdSubmodule.getId({ params: {} }); + expect(logInfoStub.calledOnce).to.be.true; + }); + + it('should read publisher id from local storage if exists', function() { + let publisherIds = ['test-pair-id1', 'test-pair-id2', 'test-pair-id3']; + sandbox.stub(storage, 'getDataFromLocalStorage').withArgs('pairId').returns(btoa(JSON.stringify(publisherIds))); + + let id = openPairIdSubmodule.getId({ params: {} }); + expect(id).to.be.deep.equal({id: publisherIds}); + }); + + it('should read publisher id from cookie if exists', function() { + let publisherIds = ['test-pair-id4', 'test-pair-id5', 'test-pair-id6']; + sandbox.stub(storage, 'getCookie').withArgs('pairId').returns(btoa(JSON.stringify(publisherIds))); + + let id = openPairIdSubmodule.getId({ params: {} }); + expect(id).to.be.deep.equal({id: publisherIds}); + }); + + it('should read publisher id from default liveramp envelope local storage key if configured', function() { + let publisherIds = ['test-pair-id1', 'test-pair-id2', 'test-pair-id3']; + sandbox.stub(storage, 'getDataFromLocalStorage').withArgs('_lr_pairId').returns(btoa(JSON.stringify({'envelope': publisherIds}))); + let id = openPairIdSubmodule.getId({ + params: { + liveramp: {} + }}) + expect(id).to.be.deep.equal({id: publisherIds}) + }); + + it('should read publisher id from default liveramp envelope cookie entry if configured', function() { + let publisherIds = ['test-pair-id4', 'test-pair-id5', 'test-pair-id6']; + sandbox.stub(storage, 'getDataFromLocalStorage').withArgs('_lr_pairId').returns(btoa(JSON.stringify({'envelope': publisherIds}))); + let id = openPairIdSubmodule.getId({ + params: { + liveramp: {} + }}) + expect(id).to.be.deep.equal({id: publisherIds}) + }); + + it('should read publisher id from specified liveramp envelope cookie entry if configured with storageKey', function() { + let publisherIds = ['test-pair-id7', 'test-pair-id8', 'test-pair-id9']; + sandbox.stub(storage, 'getDataFromLocalStorage').withArgs('lr_pairId_custom').returns(btoa(JSON.stringify({'envelope': publisherIds}))); + let id = openPairIdSubmodule.getId({ + params: { + liveramp: { + storageKey: 'lr_pairId_custom' + } + }}) + expect(id).to.be.deep.equal({id: publisherIds}) + }); + + it('should not get data from storage if local storage and cookies are disabled', function () { + sandbox.stub(storage, 'localStorageIsEnabled').returns(false); + sandbox.stub(storage, 'cookiesAreEnabled').returns(false); + let id = openPairIdSubmodule.getId({ + params: { + liveramp: { + storageKey: 'lr_pairId_custom' + } + } + }) + expect(id).to.equal(undefined) + }); + + it('honors inserter, matcher', () => { + const config = { + inserter: 'some-domain.com', + matcher: 'another-domain.com' + }; + + const result = openPairIdSubmodule.eids.openPairId(['some-random-id-value'], config); + + expect(result.length).to.equal(1); + + expect(result[0]).to.deep.equal( + { + source: 'pair-protocol.com', + mm: 3, + inserter: 'some-domain.com', + matcher: 'another-domain.com', + uids: [ + { + atype: 3, + id: 'some-random-id-value' + } + ] + } + ); + }); + + describe('eid', () => { + before(() => { + attachIdSystem(openPairIdSubmodule); + }); + + it('generates the minimal eids', function() { + const userId = { + openPairId: 'some-random-id-value' + }; + + const newEids = createEidsArray(userId); + + expect(newEids.length).to.equal(1); + + expect(newEids[0]).to.deep.include({ + source: 'pair-protocol.com', + mm: 3, + uids: [{ id: 'some-random-id-value', atype: 3 }] + }); + }); + }); +});