diff --git a/integrationExamples/gpt/raveltechRtdProvider_example.html b/integrationExamples/gpt/raveltechRtdProvider_example.html new file mode 100644 index 00000000000..97ff28d0ea6 --- /dev/null +++ b/integrationExamples/gpt/raveltechRtdProvider_example.html @@ -0,0 +1,379 @@ + + + + + + + + + + + + + + User ID Modules Example + + + + + + + + + + + + + +

User ID Modules Example

+ +

Generated EIDs

+ +

+
+  

Ad Slot

+
+ +
+ + + diff --git a/modules/raveltechRtdProvider.js b/modules/raveltechRtdProvider.js new file mode 100644 index 00000000000..adac49c3258 --- /dev/null +++ b/modules/raveltechRtdProvider.js @@ -0,0 +1,131 @@ +import {submodule, getHook} from '../src/hook.js'; +import adapterManager from '../src/adapterManager.js'; +import {logInfo, deepClone, isArray, isStr, isPlainObject, logError} from '../src/utils.js'; + +// Constants +const MODULE_NAME = 'raveltech'; +const RAVEL_ENDPOINT = 'https://pb1.rvlproxy.net/bid/bid'; + +const getAdapterNameForAlias = (aliasName) => adapterManager.aliasRegistry[aliasName] || aliasName; + +const getAnonymizedEids = (eids) => { + const ZKAD = window.ZKAD || { anonymizeID(v, p) { return undefined; } }; + logInfo('ZKAD.ready=', ZKAD.ready); + if (!eids) { return eids; } + + eids.forEach(eid => { + if (!eid || !eid.uids || eid.uids.length === 0) { return eid } + logInfo('eid.source=', eid.source); + eid.uids = eid.uids.flatMap(uid => { + if (!uid || !uid.id) { return []; } + const id = ZKAD.anonymizeID(uid.id, eid.source); + if (!id) { + logError('Error while anonymizing uid :', eid, uid); + return []; + } + logInfo('Anonymized as byte array of length=', id.length); + return [ { + ...uid, + id + } ]; + }) + }) + + return eids; +}; + +const addRavelDataToRequest = (request, adapterName) => { + if (isStr(request.data)) { + try { + const data = JSON.parse(request.data); + data.ravel = { pbjsAdapter: adapterName }; + request.data = JSON.stringify(data); + } catch (_e) {} + } else if (!request.data) { + request.data = { ravel: { pbjsAdapter: adapterName } }; + } else if (isPlainObject(request.data)) { + request.data.ravel = { pbjsAdapter: adapterName }; + } +}; + +const wrapBuildRequests = (aliasName, preserveOriginalBid, buildRequests) => { + const adapterName = getAdapterNameForAlias(aliasName) + + return (validBidRequests, ...rest) => { + if (!window.ZKAD || !window.ZKAD.ready) { + return buildRequests(validBidRequests, ...rest); + } + let requests = preserveOriginalBid ? buildRequests(validBidRequests, ...rest) : []; + if (!isArray(requests)) { + requests = [ requests ]; + } + + try { + const ravelBidRequests = deepClone(validBidRequests); + + // Anonymize eids for ravel proxified requests + const anonymizedEids = getAnonymizedEids(ravelBidRequests[0]?.userIdAsEids); + + ravelBidRequests.forEach(bidRequest => { + // Replace original eids with anonymized eids + bidRequest.userIdAsEids = anonymizedEids; + }); + + let ravelRequests = buildRequests(ravelBidRequests, ...rest); + if (!isArray(ravelRequests) && ravelRequests) { + ravelRequests = [ ravelRequests ]; + } + if (ravelRequests) { + ravelRequests.forEach(request => { + // Proxyfy request + request.url = RAVEL_ENDPOINT; + request.method = 'POST'; + addRavelDataToRequest(request, adapterName); + }) + } + + return [ ...requests ?? [], ...ravelRequests ?? [] ]; + } catch (e) { + logError('Error while generating ravel requests :', e); + return requests; + } + } +}; + +const getBidderRequestsHook = (config) => { + const allowedBidders = config.params.bidders || []; + const preserveOriginalBid = config.params.preserveOriginalBid ?? false; + const wrappedBidders = []; + + return (next, spec, ...rest) => { + if (allowedBidders.includes(spec.code) && !wrappedBidders.includes(spec.code)) { + spec.buildRequests = wrapBuildRequests(spec.code, preserveOriginalBid, spec.buildRequests); + + wrappedBidders.push(spec.code); + } + next(spec, ...rest); + } +}; + +/** + * Init + * @param {Object} config Module configuration + * @param {boolean} _userConsent + * @returns true + */ +const init = (config, _userConsent) => { + const allowedBidders = config.params.bidders || []; + const preserveOriginalBid = config.params.preserveOriginalBid ?? false; + + getHook('processBidderRequests').before(getBidderRequestsHook(config)); + logInfo(`Raveltech RTD ready - ${preserveOriginalBid ? 'will' : `won't`} duplicate bid requests - Allowed bidders : `, allowedBidders); + return true; +}; + +export const raveltechSubmodule = { + name: MODULE_NAME, + init +}; + +// Register raveltechSubmodule as submodule of realTimeData +submodule('realTimeData', raveltechSubmodule); diff --git a/modules/raveltechRtdProvider.md b/modules/raveltechRtdProvider.md new file mode 100644 index 00000000000..60aa4273047 --- /dev/null +++ b/modules/raveltechRtdProvider.md @@ -0,0 +1,61 @@ +# Raveltech RTD Module for Prebid.js + +## Overview + +``` +Module Name: Raveltech RTD Provider +Module Type: RTD Provider +Maintainer: maintainers@raveltech.io +``` + +The RavelTech RTD (Real-Time Data) module for Prebid.js enables publishers to integrate seamlessly with Ravel Technologies' privacy-focused solution, ensuring bidder requests are anonymized before reaching SSPs and DSPs. By leveraging the Ravel Privacy Bus, this module prevents the transmission of personally identifiable information (PII) in bid requests, strengthening privacy compliance and security. + +## How It Works + +The module operates in two modes: +1. **Bid URL Replacement:** The module modifies the bid request URL of the configured bidders to pass through the Ravel proxy, ensuring that all IDs are anonymized. +2. **Bid Duplication (if `preserveOriginalBid` is enabled):** The module duplicates the original bid request, sending one request as-is and another through the Ravel proxy with anonymized IDs. + +## Configuration + +To enable the Raveltech RTD module, you need to configure it with a list of bidders and specify whether to preserve the original bid request. +For the anonymization feature to work, you also need to load a javascript in the header of your HTML page: +```html + +``` + +### Build +``` +gulp build --modules="rtdModule,raveltechRtdProvider,appnexusBidAdapter,..." +``` + +> Note that the global RTD module, `rtdModule`, is a prerequisite of the raveltech RTD module. + +### Parameters + +| Parameter | Type | Description | +|--------------------|--------|-------------| +| `bidders` | Array | A list of bidder codes (or their alias if an alias is used) that should have their bid requests anonymized via Ravel. | +| `preserveOriginalBid` | Boolean | If `true`, the original bid request is preserved, and an additional bid request is sent through the Ravel proxy. If `false`, the original bid request is replaced with the Ravel-protected request. | + +### Example Configuration + +```javascript +pbjs.setConfig({ + realTimeData: { + dataProviders: [{ + name: 'raveltech', + params: { + bidders: ['appnexus', 'rubicon'], + preserveOriginalBid: true + } + }] + } +}); +``` + +## Privacy Features + +The RavelTech RTD module allows publishers to implement the following privacy protections: +- Personally Identifiable Information (PII) is either removed or converted into Anonymized IDs (RIDs). +- Bid requests are routed through an anonymized proxy before reaching the SSP, ensuring IP address anonymization. diff --git a/test/spec/modules/raveltechRtdProvider_spec.js b/test/spec/modules/raveltechRtdProvider_spec.js new file mode 100644 index 00000000000..5adaf287bed --- /dev/null +++ b/test/spec/modules/raveltechRtdProvider_spec.js @@ -0,0 +1,108 @@ +import {hook} from '../../../src/hook'; +import {BANNER} from '../../../src/mediaTypes.js'; +import {raveltechSubmodule} from 'modules/raveltechRtdProvider'; +import adapterManager from '../../../src/adapterManager.js'; +import {registerBidder} from 'src/adapters/bidderFactory.js'; + +describe('raveltechRtdProvider', () => { + const fakeBuildRequests = sinon.spy((valibBidRequests) => { + return { method: 'POST', data: { count: valibBidRequests.length, uids: valibBidRequests[0]?.userIdAsEids }, url: 'https://www.fakebidder.com' } + }); + + const fakeZkad = sinon.spy((id) => id.substr(0, 3)); + const fakeAjax = sinon.spy(); + + const fakeBidReq = { + adUnitCode: 'adunit', + adUnitId: '123', + auctionId: 'abc', + bidId: 'abc123', + userIdAsEids: [ + { source: 'usersource.com', uids: [ { id: 'testid123', atype: 1 } ] } + ] + }; + + before(() => { + hook.ready(); + // Setup fake bidder + const stubBidder = { + code: 'test', + supportedMediaTypes: [BANNER], + buildRequests: fakeBuildRequests, + interpretResponse: () => [], + isBidRequestValid: () => true + }; + registerBidder(stubBidder); + adapterManager.aliasBidAdapter('test', 'alias1'); + adapterManager.aliasBidAdapter('test', 'alias2'); + + // Init module + raveltechSubmodule.init({ params: { bidders: [ 'alias1', 'test' ], preserveOriginalBid: true } }); + }) + + afterEach(() => { + fakeBuildRequests.resetHistory(); + fakeZkad.resetHistory(); + fakeAjax.resetHistory(); + }) + + it('do not wrap bidder not in bidders params', () => { + adapterManager.getBidAdapter('alias2').callBids({ + auctionId: '123', + bidderCode: 'alias2', + bidderRequestId: 'abc', + bids: [ { ...fakeBidReq, bidder: 'alias2' } ] + }, sinon.stub(), sinon.stub(), fakeAjax, sinon.stub(), sinon.stub()); + expect(fakeAjax.calledOnce).to.be.true; + expect(fakeZkad.called).to.be.false; + expect(fakeBuildRequests.calledOnce).to.be.true; + expect(fakeAjax.getCall(0).args[2]).to.contain('"id":"testid123"'); + expect(fakeAjax.getCall(0).args[2]).not.to.contain('"pbjsAdapter":"test"'); + }) + + it('wrap bidder only by alias', () => { + adapterManager.getBidAdapter('alias2').callBids({ + auctionId: '123', + bidderCode: 'test', + bidderRequestId: 'abc', + bids: [ { ...fakeBidReq, bidder: 'test' } ] + }, sinon.stub(), sinon.stub(), fakeAjax, sinon.stub(), sinon.stub()); + expect(fakeAjax.calledOnce).to.be.true; + expect(fakeZkad.called).to.be.false; + expect(fakeBuildRequests.calledOnce).to.be.true; + expect(fakeAjax.getCall(0).args[2]).to.contain('"id":"testid123"'); + expect(fakeAjax.getCall(0).args[2]).not.to.contain('"pbjsAdapter":"test"'); + }) + + it('do not call ravel when ZKAD unavailable', () => { + adapterManager.getBidAdapter('alias1').callBids({ + auctionId: '123', + bidderCode: 'test', + bidderRequestId: 'abc', + bids: [ { ...fakeBidReq, bidder: 'test' } ] + }, sinon.stub(), sinon.stub(), fakeAjax, sinon.stub(), sinon.stub()); + expect(fakeAjax.calledOnce).to.be.true; + expect(fakeZkad.called).to.be.false; + expect(fakeBuildRequests.calledOnce).to.be.true; + expect(fakeAjax.getCall(0).args[2]).to.contain('"id":"testid123"'); + expect(fakeAjax.getCall(0).args[2]).not.to.contain('"pbjsAdapter":"test"'); + }) + + it('successfully replace uids with ZKAD', () => { + window.ZKAD = { anonymizeID: fakeZkad, ready: true }; + adapterManager.getBidAdapter('alias1').callBids({ + auctionId: '123', + bidderCode: 'test', + bidderRequestId: 'abc', + bids: [ { ...fakeBidReq, bidder: 'test' } ] + }, sinon.stub(), sinon.stub(), fakeAjax, sinon.stub(), sinon.stub()); + expect(fakeAjax.calledTwice).to.be.true; + expect(fakeZkad.calledOnce).to.be.true; + expect(fakeBuildRequests.calledTwice).to.be.true; + expect(fakeAjax.getCall(0).args[2]).to.contain('"id":"testid123"'); + expect(fakeAjax.getCall(1).args[2]).not.to.contain('"id":"testid123"'); + expect(fakeAjax.getCall(1).args[2]).to.contain('"id":"tes"'); + expect(fakeAjax.getCall(0).args[2]).not.to.contain('"pbjsAdapter":"test"'); + expect(fakeAjax.getCall(1).args[2]).to.contain('"pbjsAdapter":"test"'); + }) +})