diff --git a/modules/.submodules.json b/modules/.submodules.json index 244104c93da..bfc72495b31 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -51,6 +51,7 @@ "pubmaticIdSystem", "rewardedInterestIdSystem", "sharedIdSystem", + "startioIdSystem", "taboolaIdSystem", "tapadIdSystem", "teadsIdSystem", diff --git a/modules/startioBidAdapter.js b/modules/startioBidAdapter.js index fe1d10bf9a2..e5a24ea0861 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=m4b8b3y4'; 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 172af1aeb4e..b727acd3195 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,32 @@ 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', + storage: { + type: 'cookie&html5', + 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/startioIdSystem.js b/modules/startioIdSystem.js new file mode 100644 index 00000000000..637d69a65a3 --- /dev/null +++ b/modules/startioIdSystem.js @@ -0,0 +1,74 @@ +/** + * This module adds startio ID support to the User ID module + * The {@link module:modules/userId} module is required + * @module modules/startioIdSystem + * @requires module:modules/userId + */ +import { logError, formatQS } from '../src/utils.js'; +import { submodule } from '../src/hook.js'; +import { ajax } from '../src/ajax.js'; +import { getUserSyncParams } from '../libraries/userSyncUtils/userSyncUtils.js'; + +const MODULE_NAME = 'startioId'; +const GVLID = 1216; +const DEFAULT_ENDPOINT = 'https://cs.startappnetwork.com/get-uid-obj?p=m4b8b3y4'; + +function fetchIdFromServer(callback, consentData) { + const consentParams = getUserSyncParams( + consentData?.gdpr, + consentData?.usp, + consentData?.gpp + ); + const queryString = formatQS(consentParams); + const url = queryString ? `${DEFAULT_ENDPOINT}&${queryString}` : DEFAULT_ENDPOINT; + + const callbacks = { + success: response => { + let responseId; + try { + const responseObj = JSON.parse(response); + if (responseObj && responseObj.uid) { + responseId = responseObj.uid; + } 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(url, callbacks, undefined, { method: 'GET', withCredentials: true }); +} + +export const startioIdSubmodule = { + name: MODULE_NAME, + gvlid: GVLID, + decode(value) { + return value && typeof value === 'string' + ? { 'startioId': value } + : undefined; + }, + getId(config, consentData, storedId) { + if (storedId) { + return { id: storedId }; + } + if (config.storage && config.storage.expires == null) { + config.storage.expires = 90; + } + return { callback: (cb) => fetchIdFromServer(cb, consentData) }; + }, + + eids: { + 'startioId': { + source: 'start.io', + atype: 1 + }, + } +}; + +submodule('userId', startioIdSubmodule); diff --git a/modules/startioIdSystem.md b/modules/startioIdSystem.md new file mode 100644 index 00000000000..5c3de1bf961 --- /dev/null +++ b/modules/startioIdSystem.md @@ -0,0 +1,44 @@ +## Start.io User ID Submodule + +The Start.io User ID submodule generates and persists a unique user identifier by fetching it from a Start.io-managed endpoint. This endpoint is fixed within the submodule implementation and is not configurable via Prebid.js parameters. 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', + storage: { + type: 'cookie&html5', // 'cookie', 'html5', or 'cookie&html5' + name: 'startioId', + expires: 90 // optional, 90 days by default + } + }], + 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"` | +| storage | Required | Object | Storage configuration for the user ID. | | +| storage.type | Required | String | Type of storage: `"cookie"`, `"html5"`, or `"cookie&html5"`. | `"cookie&html5"` | +| storage.name | Required | String | The name used to store the user ID. | `"startioId"` | +| storage.expires | Optional | Number | Number of days before the stored ID expires. Defaults to `90`. | `365` | diff --git a/modules/userId/eids.md b/modules/userId/eids.md index bdd8a0bb3e8..b2fb808bafc 100644 --- a/modules/userId/eids.md +++ b/modules/userId/eids.md @@ -350,6 +350,13 @@ userIdAsEids = [ id: 'some-random-id-value', atype: 1 }] + }, + { + source: 'start.io', + uids: [{ + id: 'some-random-id-value', + atype: 1 + }] } ] ``` diff --git a/modules/userId/userId.md b/modules/userId/userId.md index 328e23d96b0..ca2b3fb525d 100644 --- a/modules/userId/userId.md +++ b/modules/userId/userId.md @@ -157,6 +157,9 @@ pbjs.setConfig({ }, { name: "mygaruId" + }, + { + name: "startioId" } ], syncDelay: 5000, diff --git a/test/spec/modules/startioBidAdapter_spec.js b/test/spec/modules/startioBidAdapter_spec.js index 2b7269997aa..1541007cc74 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=m4b8b3y4'); + }); + }); }); diff --git a/test/spec/modules/startioIdSystem_spec.js b/test/spec/modules/startioIdSystem_spec.js new file mode 100644 index 00000000000..dbdc2096bbf --- /dev/null +++ b/test/spec/modules/startioIdSystem_spec.js @@ -0,0 +1,215 @@ +import * as utils from '../../../src/utils.js'; +import { server } from 'test/mocks/xhr.js'; +import { startioIdSubmodule } from 'modules/startioIdSystem.js'; +import { createEidsArray } from '../../../modules/userId/eids.js'; + +describe('StartIO ID System', function () { + let sandbox; + + const validConfig = { + params: {}, + storage: { + expires: 365 + } + }; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + sandbox.stub(utils, 'logError'); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('module registration', function () { + it('should register the submodule', function () { + expect(startioIdSubmodule.name).to.equal('startioId'); + }); + + it('should have gvlid', function () { + expect(startioIdSubmodule.gvlid).to.equal(1216); + }); + + it('should have eids configuration', function () { + expect(startioIdSubmodule.eids).to.deep.equal({ + 'startioId': { + source: 'start.io', + atype: 1 + } + }); + }); + }); + + 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: 1, + 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=m4b8b3y4'); + + 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 append consent params to the request URL', function () { + const consentData = { + gdpr: { + gdprApplies: true, + consentString: 'TEST_CONSENT_STRING' + }, + usp: '1YNN', + gpp: { + gppString: 'TEST_GPP_STRING', + applicableSections: [7] + } + }; + + const callbackSpy = sinon.spy(); + const callback = startioIdSubmodule.getId(validConfig, consentData).callback; + callback(callbackSpy); + + const request = server.requests[0]; + expect(request.url).to.include('gdpr=1'); + expect(request.url).to.include('gdpr_consent=TEST_CONSENT_STRING'); + expect(request.url).to.include('us_privacy=1YNN'); + expect(request.url).to.include('gpp=TEST_GPP_STRING'); + expect(request.url).to.include('gpp_sid=7'); + }); + + it('should send request without consent params when consentData is absent', function () { + const callbackSpy = sinon.spy(); + const callback = startioIdSubmodule.getId(validConfig).callback; + callback(callbackSpy); + + const request = server.requests[0]; + expect(request.url).to.eq('https://cs.startappnetwork.com/get-uid-obj?p=m4b8b3y4'); + }); + + 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'); + }); + + it('should set default storage.expires to 90 when not provided', function () { + const config = { params: {}, storage: { type: 'html5', name: 'startioId' } }; + startioIdSubmodule.getId(config); + expect(config.storage.expires).to.equal(90); + }); + + it('should not override storage.expires when already set', function () { + const config = { params: {}, storage: { type: 'html5', name: 'startioId', expires: 365 } }; + startioIdSubmodule.getId(config); + expect(config.storage.expires).to.equal(365); + }); + }); +});