diff --git a/modules/startioBidAdapter.js b/modules/startioBidAdapter.js index 74629f2cc9..6637dd219b 100644 --- a/modules/startioBidAdapter.js +++ b/modules/startioBidAdapter.js @@ -1,13 +1,15 @@ import { registerBidder } from '../src/adapters/bidderFactory.js'; import { BANNER, VIDEO, NATIVE } from '../src/mediaTypes.js'; -import { logError, isFn, isPlainObject } from '../src/utils.js'; +import { logError, isFn, isPlainObject, formatQS } from '../src/utils.js'; import { ortbConverter } from '../libraries/ortbConverter/converter.js' import { ortb25Translator } from '../libraries/ortb2.5Translator/translator.js'; +import { getUserSyncParams } from '../libraries/userSyncUtils/userSyncUtils.js'; const BIDDER_CODE = 'startio'; const METHOD = 'POST'; const GVLID = 1216; const ENDPOINT_URL = `https://pbc-rtb.startappnetwork.com/1.3/2.5/getbid?account=pbc`; +const IFRAME_URL = 'https://cs.startappnetwork.com/sync?p=1002'; const converter = ortbConverter({ imp(buildImp, bidRequest, context) { @@ -151,6 +153,23 @@ export const spec = { }, onSetTargeting: (bid) => { }, + + getUserSyncs: function(syncOptions, serverResponses, gdprConsent, uspConsent, gppConsent) { + const syncs = []; + + if (syncOptions.iframeEnabled) { + const consentParams = getUserSyncParams(gdprConsent, uspConsent, gppConsent); + const queryString = formatQS(consentParams); + const queryParam = queryString ? `&${queryString}` : ''; + + syncs.push({ + type: 'iframe', + url: `${IFRAME_URL}${queryParam}` + }); + } + + return syncs; + } }; registerBidder(spec); diff --git a/modules/startioBidAdapter.md b/modules/startioBidAdapter.md index 172af1aeb4..d74e0e64d3 100644 --- a/modules/startioBidAdapter.md +++ b/modules/startioBidAdapter.md @@ -25,7 +25,7 @@ var adUnits = [ bidder: 'startio', params: { // REQUIRED - Publisher Account ID - accountId: 'your-account-id', + publisherId: 'your-account-id', // OPTIONAL - Enable test ads testAdsEnabled: true @@ -58,7 +58,7 @@ var videoAdUnits = [ { bidder: 'startio', params: { - accountId: 'your-account-id', + publisherId: 'your-account-id', testAdsEnabled: true } } @@ -85,7 +85,7 @@ var nativeAdUnits = [ { bidder: 'startio', params: { - accountId: 'your-account-id', + publisherId: 'your-account-id', testAdsEnabled: true } } @@ -94,8 +94,28 @@ var nativeAdUnits = [ ]; ``` +### Prebid Params Enabling User Sync + +To enable iframe-based user syncing for Start.io, include the `filterSettings` configuration in your `userSync` setup: + +```javascript +pbjs.setConfig({ + userSync: { + userIds: [{ + name: 'startioId' + }], + filterSettings: { + iframe: { + bidders: ['startio'], + filter: 'include' + } + } + } +}); +``` + # Additional Notes - The adapter processes requests via OpenRTB 2.5 standards. -- Ensure that the `accountId` parameter is set correctly for your integration. +- Ensure that the `publisherId` parameter is set correctly for your integration. - Test ads can be enabled using `testAdsEnabled: true` during development. - The adapter supports multiple ad formats, allowing publishers to serve banners, native ads and instream video ads seamlessly. diff --git a/modules/startioSystem.js b/modules/startioSystem.js new file mode 100644 index 0000000000..87dd9354ac --- /dev/null +++ b/modules/startioSystem.js @@ -0,0 +1,106 @@ +/** + * This module adds startio ID support to the User ID module + * The {@link module:modules/userId} module is required + * @module modules/startioSystem + * @requires module:modules/userId + */ +import { logError } from '../src/utils.js'; +import { submodule } from '../src/hook.js'; +import { ajax } from '../src/ajax.js'; +import { getStorageManager } from '../src/storageManager.js'; +import { MODULE_TYPE_UID } from '../src/activities/modules.js'; + +const MODULE_NAME = 'startioId'; +const DEFAULT_ENDPOINT = 'https://cs.startappnetwork.com/get-uid-obj?p=1002'; + +const storage = getStorageManager({moduleType: MODULE_TYPE_UID, moduleName: MODULE_NAME}); + +function getCachedId() { + let cachedId; + + if (storage.cookiesAreEnabled()) { + cachedId = storage.getCookie(MODULE_NAME); + } + + if (!cachedId && storage.hasLocalStorage()) { + const expirationStr = storage.getDataFromLocalStorage(`${MODULE_NAME}_exp`); + if (expirationStr) { + const expirationDate = new Date(expirationStr); + if (expirationDate > new Date()) { + cachedId = storage.getDataFromLocalStorage(MODULE_NAME); + } + } + } + + return cachedId || null; +} + +function storeId(id, expiresInDays) { + expiresInDays = expiresInDays || 90; + const expirationDate = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toUTCString(); + + if (storage.cookiesAreEnabled()) { + storage.setCookie(MODULE_NAME, id, expirationDate, 'None'); + } + + if (storage.hasLocalStorage()) { + storage.setDataInLocalStorage(`${MODULE_NAME}_exp`, expirationDate); + storage.setDataInLocalStorage(MODULE_NAME, id); + } +} + +function fetchIdFromServer(callback, expiresInDays) { + const callbacks = { + success: response => { + let responseId; + try { + const responseObj = JSON.parse(response); + if (responseObj && responseObj.uid) { + responseId = responseObj.uid; + storeId(responseId, expiresInDays); + } else { + logError(`${MODULE_NAME}: Server response missing 'uid' field`); + } + } catch (error) { + logError(`${MODULE_NAME}: Error parsing server response`, error); + } + callback(responseId); + }, + error: error => { + logError(`${MODULE_NAME}: ID fetch encountered an error`, error); + callback(); + } + }; + ajax(DEFAULT_ENDPOINT, callbacks, undefined, { method: 'GET' }); +} + +export const startioIdSubmodule = { + name: MODULE_NAME, + decode(value) { + return value && typeof value === 'string' + ? { 'startioId': value } + : undefined; + }, + getId(config, consentData, storedId) { + if (storedId) { + return { id: storedId }; + } + + const cachedId = getCachedId(); + if (cachedId) { + return { id: cachedId }; + } + const storageConfig = config && config.storage; + const expiresInDays = storageConfig && storageConfig.expires; + return { callback: (cb) => fetchIdFromServer(cb, expiresInDays) }; + }, + + eids: { + 'startioId': { + source: 'start.io', + atype: 3 + }, + } +}; + +submodule('userId', startioIdSubmodule); diff --git a/modules/startioSystem.md b/modules/startioSystem.md new file mode 100644 index 0000000000..84bee91fa8 --- /dev/null +++ b/modules/startioSystem.md @@ -0,0 +1,35 @@ +## Start.io User ID Submodule + +The Start.io User ID submodule generates and persists a unique user identifier by fetching it from a publisher-supplied endpoint. The ID is stored in both cookies and local storage for subsequent page loads and is made available to other Prebid.js modules via the standard `eids` interface. + +For integration support, contact prebid@start.io. + +### Prebid Params Enabling User Sync + +To enable iframe-based user syncing for Start.io, include the `filterSettings` configuration in your `userSync` setup: + +```javascript +pbjs.setConfig({ + userSync: { + userIds: [{ + name: 'startioId' + }], + filterSettings: { + iframe: { + bidders: ['startio'], + filter: 'include' + } + } + } +}); +``` + +This configuration allows Start.io to sync user data via iframe, which is necessary for cross-domain user identification. + +## Parameter Descriptions for the `userSync` Configuration Section + +The below parameters apply only to the Start.io User ID integration. + +| Param under userSync.userIds[] | Scope | Type | Description | Example | +| --- | --- | --- | --- | --- | +| name | Required | String | The name of this module. | `"startioId"` | diff --git a/test/spec/modules/startioBidAdapter_spec.js b/test/spec/modules/startioBidAdapter_spec.js index 021c11e80d..0b21e23bf8 100644 --- a/test/spec/modules/startioBidAdapter_spec.js +++ b/test/spec/modules/startioBidAdapter_spec.js @@ -370,4 +370,98 @@ describe('Prebid Adapter: Startio', function () { }); } }); + + describe('getUserSyncs', function () { + it('should return an iframe sync when iframeEnabled is true', function () { + const syncs = spec.getUserSyncs({ iframeEnabled: true }, []); + + expect(syncs).to.have.lengthOf(1); + expect(syncs[0].type).to.equal('iframe'); + expect(syncs[0].url).to.be.a('string'); + }); + + it('should return an empty array when iframeEnabled is false', function () { + const syncs = spec.getUserSyncs({ iframeEnabled: false }, []); + + expect(syncs).to.have.lengthOf(0); + }); + + it('should return an empty array when syncOptions is empty', function () { + const syncs = spec.getUserSyncs({}, []); + + expect(syncs).to.have.lengthOf(0); + }); + + it('should append GDPR consent params to the sync URL', function () { + const gdprConsent = { + gdprApplies: true, + consentString: 'BOJ/P2HOJ/P2HABABMAAAAAZ+A==' + }; + + const syncs = spec.getUserSyncs({ iframeEnabled: true }, [], gdprConsent); + + expect(syncs).to.have.lengthOf(1); + expect(syncs[0].url).to.include('gdpr=1'); + expect(syncs[0].url).to.include('gdpr_consent=BOJ/P2HOJ/P2HABABMAAAAAZ+A=='); + }); + + it('should append gdpr=0 when gdprApplies is false', function () { + const gdprConsent = { + gdprApplies: false, + consentString: '' + }; + + const syncs = spec.getUserSyncs({ iframeEnabled: true }, [], gdprConsent); + + expect(syncs[0].url).to.include('gdpr=0'); + }); + + it('should append USP consent param to the sync URL', function () { + const syncs = spec.getUserSyncs({ iframeEnabled: true }, [], undefined, '1YNN'); + + expect(syncs).to.have.lengthOf(1); + expect(syncs[0].url).to.include('us_privacy=1YNN'); + }); + + it('should append GPP consent params to the sync URL', function () { + const gppConsent = { + gppString: 'DBABMA~BAAAAAAAAgA.QA', + applicableSections: [7, 8] + }; + + const syncs = spec.getUserSyncs({ iframeEnabled: true }, [], undefined, undefined, gppConsent); + + expect(syncs).to.have.lengthOf(1); + expect(syncs[0].url).to.include('gpp=DBABMA~BAAAAAAAAgA.QA'); + expect(syncs[0].url).to.include('gpp_sid=7,8'); + }); + + it('should append all consent params together when all are provided', function () { + const gdprConsent = { + gdprApplies: true, + consentString: 'testConsent' + }; + const uspConsent = '1YNN'; + const gppConsent = { + gppString: 'testGpp', + applicableSections: [2] + }; + + const syncs = spec.getUserSyncs({ iframeEnabled: true }, [], gdprConsent, uspConsent, gppConsent); + + expect(syncs).to.have.lengthOf(1); + expect(syncs[0].url).to.include('gdpr=1'); + expect(syncs[0].url).to.include('gdpr_consent=testConsent'); + expect(syncs[0].url).to.include('us_privacy=1YNN'); + expect(syncs[0].url).to.include('gpp=testGpp'); + expect(syncs[0].url).to.include('gpp_sid=2'); + }); + + it('should not append query string when no consent params are provided', function () { + const syncs = spec.getUserSyncs({ iframeEnabled: true }, []); + + expect(syncs).to.have.lengthOf(1); + expect(syncs[0].url).to.equal('https://cs.startappnetwork.com/sync?p=1002'); + }); + }); }); diff --git a/test/spec/modules/startioSystem_spec.js b/test/spec/modules/startioSystem_spec.js new file mode 100644 index 0000000000..eff2cc431e --- /dev/null +++ b/test/spec/modules/startioSystem_spec.js @@ -0,0 +1,177 @@ +import * as utils from '../../../src/utils.js'; +import { server } from 'test/mocks/xhr.js'; +import { startioIdSubmodule } from 'modules/startioSystem.js'; +import { createEidsArray } from '../../../modules/userId/eids.js'; +import { getStorageManager } from '../../../src/storageManager.js'; + +describe('StartIO ID System', function () { + let sandbox; + let storage; + + const validConfig = { + params: {}, + storage: { + expires: 365 + } + }; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + sandbox.stub(utils, 'logError'); + storage = getStorageManager({ moduleType: 'userId', moduleName: 'startioId' }); + + // Clear any cached storage + if (storage.cookiesAreEnabled()) { + storage.setCookie('startioId', '', new Date(0).toUTCString()); + } + if (storage.hasLocalStorage()) { + storage.removeDataFromLocalStorage('startioId'); + storage.removeDataFromLocalStorage('startioId_exp'); + } + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('module registration', function () { + it('should register the submodule', function () { + expect(startioIdSubmodule.name).to.equal('startioId'); + }); + + it('should have eids configuration', function () { + expect(startioIdSubmodule.eids).to.deep.equal({ + 'startioId': { + source: 'start.io', + atype: 3 + } + }); + }); + }); + + describe('decode', function () { + it('should return undefined if no value passed', function () { + expect(startioIdSubmodule.decode()).to.be.undefined; + }); + + it('should return undefined if invalid value passed', function () { + expect(startioIdSubmodule.decode(123)).to.be.undefined; + expect(startioIdSubmodule.decode(null)).to.be.undefined; + expect(startioIdSubmodule.decode({})).to.be.undefined; + expect(startioIdSubmodule.decode('')).to.be.undefined; + }); + + it('should return startioId object if valid string passed', function () { + const id = 'test-uuid-12345'; + const result = startioIdSubmodule.decode(id); + expect(result).to.deep.equal({ 'startioId': id }); + }); + }); + + describe('eid', function () { + it('should generate correct EID', function () { + const TEST_UID = 'test-uid-value'; + const eids = createEidsArray(startioIdSubmodule.decode(TEST_UID), new Map(Object.entries(startioIdSubmodule.eids))); + expect(eids).to.eql([ + { + source: 'start.io', + uids: [ + { + atype: 3, + id: TEST_UID + } + ] + } + ]); + }); + }); + + describe('getId', function () { + it('should return callback and fire ajax even if no endpoint configured', function () { + const config = { params: {} }; + const result = startioIdSubmodule.getId(config); + expect(result).to.have.property('callback'); + expect(typeof result.callback).to.equal('function'); + + const callbackSpy = sinon.spy(); + result.callback(callbackSpy); + expect(server.requests.length).to.equal(1); + }); + + it('should return callback and fire ajax even if endpoint is not a string', function () { + const config = { params: { endpoint: 123 } }; + const result = startioIdSubmodule.getId(config); + expect(result).to.have.property('callback'); + expect(typeof result.callback).to.equal('function'); + + const callbackSpy = sinon.spy(); + result.callback(callbackSpy); + expect(server.requests.length).to.equal(1); + }); + + it('should return existing storedId immediately if provided', function () { + const storedId = 'existing-id-12345'; + const result = startioIdSubmodule.getId(validConfig, {}, storedId); + expect(result).to.deep.equal({ id: storedId }); + expect(server.requests.length).to.eq(0); + }); + + it('should fetch new ID from server if no storedId provided', function () { + const result = startioIdSubmodule.getId(validConfig); + expect(result).to.have.property('callback'); + expect(typeof result.callback).to.equal('function'); + }); + + it('should invoke callback with ID from server response', function () { + const callbackSpy = sinon.spy(); + const callback = startioIdSubmodule.getId(validConfig).callback; + callback(callbackSpy); + + const request = server.requests[0]; + expect(request.method).to.eq('GET'); + expect(request.url).to.eq('https://cs.startappnetwork.com/get-uid-obj?p=1002'); + + const serverId = 'new-server-id-12345'; + request.respond(200, { 'Content-Type': 'application/json' }, JSON.stringify({ uid: serverId })); + + expect(callbackSpy.calledOnce).to.be.true; + expect(callbackSpy.lastCall.lastArg).to.equal(serverId); + }); + + it('should log error if server response is missing uid field', function () { + const callbackSpy = sinon.spy(); + const callback = startioIdSubmodule.getId(validConfig).callback; + callback(callbackSpy); + + const request = server.requests[0]; + request.respond(200, { 'Content-Type': 'application/json' }, JSON.stringify({ wrongField: 'value' })); + + expect(utils.logError.calledOnce).to.be.true; + expect(utils.logError.args[0][0]).to.include('missing \'uid\' field'); + }); + + it('should log error if server response is invalid JSON', function () { + const callbackSpy = sinon.spy(); + const callback = startioIdSubmodule.getId(validConfig).callback; + callback(callbackSpy); + + const request = server.requests[0]; + request.respond(200, { 'Content-Type': 'application/json' }, 'invalid-json{'); + + expect(utils.logError.calledOnce).to.be.true; + expect(utils.logError.args[0][0]).to.include('Error parsing'); + }); + + it('should log error if server request fails', function () { + const callbackSpy = sinon.spy(); + const callback = startioIdSubmodule.getId(validConfig).callback; + callback(callbackSpy); + + const request = server.requests[0]; + request.error(); + + expect(utils.logError.calledOnce).to.be.true; + expect(utils.logError.args[0][0]).to.include('encountered an error'); + }); + }); +});