diff --git a/modules/startioBidAdapter.js b/modules/startioBidAdapter.js index 74629f2cc9..c165b30cdc 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 = 'test'; 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..6404b77ba2 100644 --- a/modules/startioBidAdapter.md +++ b/modules/startioBidAdapter.md @@ -94,6 +94,25 @@ var nativeAdUnits = [ ]; ``` +# User Syncs + +The adapter supports iframe-based user syncing. When `iframeEnabled` is set to `true` in the sync options, the adapter returns a single iframe sync URL. GDPR, USP (CCPA), and GPP consent strings are automatically appended as query parameters when present. + +``` +pbjs.setConfig({ + userSync: { + iframeEnabled: true + } +}); +``` + +**Consent parameters included in the sync URL (when available):** +- `gdpr` – `1` if GDPR applies, `0` otherwise +- `gdpr_consent` – the TCF consent string +- `us_privacy` – the USP/CCPA consent string (e.g. `1YNN`) +- `gpp` – the GPP consent string +- `gpp_sections` – applicable GPP section IDs + # Additional Notes - The adapter processes requests via OpenRTB 2.5 standards. - Ensure that the `accountId` parameter is set correctly for your integration. diff --git a/modules/startioSystem.js b/modules/startioSystem.js new file mode 100644 index 0000000000..9d2ecfd812 --- /dev/null +++ b/modules/startioSystem.js @@ -0,0 +1,63 @@ +/** + * 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'; + +const MODULE_NAME = 'startioId'; +const DEFAULT_ENDPOINT = ''; + +export const startioIdSubmodule = { + name: MODULE_NAME, + decode(value) { + return value && typeof value === 'string' + ? { 'startioId': value } + : undefined; + }, + getId(config, consentData, storedId) { + const configParams = (config && config.params) || {}; + const endpoint = configParams.endpoint || DEFAULT_ENDPOINT; + + if (storedId) { + return { id: storedId }; + } + + const resp = function (callback) { + const callbacks = { + success: response => { + let responseId; + try { + const responseObj = JSON.parse(response); + if (responseObj && responseObj.id) { + responseId = responseObj.id; + } else { + logError(`${MODULE_NAME}: Server response missing 'id' 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(endpoint, callbacks, undefined, { method: 'GET' }); + }; + return { callback: resp }; + }, + + 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..53b6e03531 --- /dev/null +++ b/modules/startioSystem.md @@ -0,0 +1,50 @@ +## 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 + +``` +pbjs.setConfig({ + userSync: { + userIds: [{ + name: 'startioId' + }] + } +}); +``` + +## 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"` | + +## Server Response Format + +The endpoint specified in `params.endpoint` must return a JSON response containing an `id` field: + +``` +{ + "id": "unique-user-identifier-string" +} +``` + +If the `id` field is missing or the response cannot be parsed, the module logs an error and does not store a value. + +## How It Works + +1. On the first page load (no stored ID exists), the module sends a `GET` request to the configured `endpoint`. +2. The returned `id` is written to both cookies and local storage (respecting the `storage` configuration). +3. On subsequent loads the stored ID is returned directly — no network request is made. +4. The ID is exposed to other modules via the extended ID (`eids`) framework with source `start.io` and `atype: 3`. + +## Notes + +- The `endpoint` parameter is required. The module will log an error and return no ID if it is missing or not a string. +- Storage defaults to both cookies and local storage when no explicit `storage.type` is provided. The module checks whether each mechanism is available before writing. +- Cookie expiration is set to `storage.expires` days from the time the ID is first fetched (default 365 days). diff --git a/test/spec/modules/startioBidAdapter_spec.js b/test/spec/modules/startioBidAdapter_spec.js index 021c11e80d..4abb0f3bd2 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.not.include('?'); + }); + }); }); diff --git a/test/spec/modules/startioSystem_spec.js b/test/spec/modules/startioSystem_spec.js new file mode 100644 index 0000000000..ac20ef18b9 --- /dev/null +++ b/test/spec/modules/startioSystem_spec.js @@ -0,0 +1,167 @@ +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'; + +describe('StartIO ID System', function () { + let sandbox; + + const validConfig = { + params: { + endpoint: 'https://test-endpoint.start.io/getId' + }, + 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 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(validConfig.params.endpoint); + + const serverId = 'new-server-id-12345'; + request.respond(200, { 'Content-Type': 'application/json' }, JSON.stringify({ id: serverId })); + + expect(callbackSpy.calledOnce).to.be.true; + expect(callbackSpy.lastCall.lastArg).to.equal(serverId); + }); + + it('should log error if server response is missing id 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 \'id\' 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'); + }); + }); +});