Skip to content

Commit

Permalink
GPP: usnat support (prebid#10117)
Browse files Browse the repository at this point in the history
* share CMP client code

* consentManagementGpp: fetch section data

* mock out getSection for legacy tests

* Make gppConsent available as an activity param; also fix various test suites to set up during set up

* gppControl_usnat

* load all section data (instead of trying to figure out what is applicable)

* Do not expect top window to be accessible

* update usnat consent interpretation

* Update activityControls.js

* Update activityControls.js

* Update activityControls_spec.js

* Update activityControls.js

* Update activityControls.js

* Update activityControls_spec.js

* Update activityControls.js

* Update activityControls.js

* Update activityControls.js

* Update activityControls_spec.js

* Update activityControls_spec.js

* Update activityControls_spec.js

* Update activityControls.js

* Update activityControls_spec.js

* Update activityControls_spec.js

* Update activityControls_spec.js

* Update activityControls_spec.js

* Update activityControls_spec.js

* Update activityControls_spec.js

* Update activityControls_spec.js

* Update activityControls.js

* Update activityControls_spec.js

* Update activityControls_spec.js

* Update activityControls_spec.js

* Update activityControls.js

* Update activityControls_spec.js

* Update activityControls_spec.js

* Update activityControls_spec.js

* Update activityControls.js

* Update activityControls_spec.js

* Update activityControls.js

* Update activityControls.js

* Update activityControls.js

---------

Co-authored-by: Patrick McCann <[email protected]>
  • Loading branch information
dgirardi and patmmccann authored Jun 27, 2023
1 parent 8f075c4 commit 5e22f2f
Show file tree
Hide file tree
Showing 24 changed files with 1,176 additions and 466 deletions.
139 changes: 139 additions & 0 deletions libraries/cmp/cmpClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {GreedyPromise} from '../../src/utils/promise.js';

/**
* @typedef {function} CMPClient
*
* @param {{}} params CMP parameters. Currently this is a subset of {command, callback, parameter, version}.
* @returns {Promise<*>} a promise that:
* - if a `callback` param was provided, resolves (with no result) just before the first time it's run;
* - if `callback` was *not* provided, resolves to the return value of the CMP command
* @property {boolean} isDirect true if the CMP is directly accessible (no postMessage required)
*/

/**
* Returns a function that can interface with a CMP regardless of where it's located.
*
* @param apiName name of the CMP api, e.g. "__gpp"
* @param apiVersion? CMP API version
* @param apiArgs? names of the arguments taken by the api function, in order.
* @param callbackArgs? names of the cross-frame response payload properties that should be passed as callback arguments, in order
* @param win
* @returns {CMPClient} CMP invocation function (or null if no CMP was found).
*/
export function cmpClient(
{
apiName,
apiVersion,
apiArgs = ['command', 'callback', 'parameter', 'version'],
callbackArgs = ['returnValue', 'success'],
},
win = window
) {
const cmpCallbacks = {};
const callName = `${apiName}Call`;
const cmpDataPkgName = `${apiName}Return`;

function handleMessage(event) {
const json = (typeof event.data === 'string' && event.data.includes(cmpDataPkgName)) ? JSON.parse(event.data) : event.data;
if (json?.[cmpDataPkgName]?.callId) {
const payload = json[cmpDataPkgName];

if (cmpCallbacks.hasOwnProperty(payload.callId)) {
cmpCallbacks[payload.callId](...callbackArgs.map(name => payload[name]));
}
}
}

function findCMP() {
let f = win;
let cmpFrame;
let isDirect = false;
while (f != null) {
try {
if (typeof f[apiName] === 'function') {
cmpFrame = f;
isDirect = true;
break;
}
} catch (e) {
}

// need separate try/catch blocks due to the exception errors thrown when trying to check for a frame that doesn't exist in 3rd party env
try {
if (f.frames[`${apiName}Locator`]) {
cmpFrame = f;
break;
}
} catch (e) {
}

if (f === win.top) break;
f = f.parent;
}

return [
cmpFrame,
isDirect
];
}

const [cmpFrame, isDirect] = findCMP();

if (!cmpFrame) {
return;
}

function resolveParams(params) {
params = Object.assign({version: apiVersion}, params);
return apiArgs.map(arg => [arg, params[arg]])
}

function wrapCallback(callback, resolve, reject, preamble) {
return function (result, success) {
preamble && preamble();
const resolver = success == null || success ? resolve : reject;
if (typeof callback === 'function') {
resolver();
return callback.apply(this, arguments);
} else {
resolver(result);
}
}
}

let client;

if (isDirect) {
client = function invokeCMPDirect(params = {}) {
return new GreedyPromise((resolve, reject) => {
const ret = cmpFrame[apiName](...resolveParams({
...params,
callback: params.callback && wrapCallback(params.callback, resolve, reject)
}).map(([_, val]) => val));
if (params.callback == null) {
resolve(ret);
}
});
};
} else {
win.addEventListener('message', handleMessage, false);

client = function invokeCMPFrame(params) {
return new GreedyPromise((resolve, reject) => {
// call CMP via postMessage
const callId = Math.random().toString();
const msg = {
[callName]: {
...Object.fromEntries(resolveParams(params).filter(([param]) => param !== 'callback')),
callId: callId
}
};

cmpCallbacks[callId] = wrapCallback(params?.callback, resolve, reject, params?.callback == null && (() => { delete cmpCallbacks[callId] }));
cmpFrame.postMessage(msg, '*');
});
};
}
client.isDirect = isDirect;
return client;
}
91 changes: 91 additions & 0 deletions libraries/mspa/activityControls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {registerActivityControl} from '../../src/activities/rules.js';
import {
ACTIVITY_ENRICH_EIDS,
ACTIVITY_ENRICH_UFPD,
ACTIVITY_SYNC_USER,
ACTIVITY_TRANSMIT_PRECISE_GEO
} from '../../src/activities/activities.js';
import {gppDataHandler} from '../../src/adapterManager.js';

// default interpretation for MSPA consent(s):
// https://docs.google.com/document/d/1yPEVx3bBdSkIw-QNkQR5qi1ztmn9zoXk-LaGQB6iw7Q

export function isBasicConsentDenied(cd) {
// service provider mode is always consent denied
return ['MspaServiceProviderMode', 'Gpc'].some(prop => cd[prop] === 1) ||
// you cannot consent to what you were not notified of
cd.PersonalDataConsents === 2 ||
// minors 13+ who have not given consent
cd.KnownChildSensitiveDataConsents[0] === 1 ||
// minors under 13 cannot consent
cd.KnownChildSensitiveDataConsents[1] !== 0 ||
// sensitive category consent impossible without notice
(cd.SensitiveDataProcessing.some(element => element === 2) && (cd.SensitiveDataLimitUseNotice !== 1 || cd.SensitiveDataProcessingOptOutNotice !== 1));
}

export function isSensitiveNoticeMissing(cd) {
return ['SensitiveDataProcessingOptOutNotice', 'SensitiveDataLimitUseNotice'].some(prop => cd[prop] === 2)
}

export function isConsentDenied(cd) {
return isBasicConsentDenied(cd) ||
// user opts out
(['SaleOptOut', 'SharingOptOut', 'TargetedAdvertisingOptOut'].some(prop => cd[prop] === 1)) ||
// notice not given
(['SaleOptOutNotice', 'SharingNotice', 'SharingOptOutNotice', 'TargetedAdvertisingOptOutNotice'].some(prop => cd[prop] === 2)) ||
// sale opted in but notice does not apply
((cd.SaleOptOut === 2 && cd.SaleOptOutNotice === 0)) ||
// targeted opted in but notice does not apply
((cd.TargetedAdvertisingOptOut === 2 && cd.TargetedAdvertisingOptOutNotice === 0)) ||
// sharing opted in but notices do not apply
((cd.SharingOptOut === 2 && (cd.SharingNotice === 0 || cd.SharingOptOutNotice === 0)));
}

export function isTransmitUfpdConsentDenied(cd) {
// SensitiveDataProcessing[1-5,11]=1 OR SensitiveDataProcessing[6,7,9,10,12]<>0 OR
const mustBeZero = [6, 7, 9, 10, 12];
const mustBeZeroSubtractedVector = mustBeZero.map((number) => number - 1);
const SensitiveDataProcessingMustBeZero = cd.SensitiveDataProcessing.filter(index => mustBeZeroSubtractedVector.includes(index));
const cannotBeOne = [1, 2, 3, 4, 5, 11];
const cannotBeOneSubtractedVector = cannotBeOne.map((number) => number - 1);
const SensitiveDataProcessingCannotBeOne = cd.SensitiveDataProcessing.filter(index => cannotBeOneSubtractedVector.includes(index));
return isConsentDenied(cd) ||
isSensitiveNoticeMissing(cd) ||
SensitiveDataProcessingCannotBeOne.some(val => val === 1) ||
SensitiveDataProcessingMustBeZero.some(val => val !== 0);
}

export function isTransmitGeoConsentDenied(cd) {
return isBasicConsentDenied(cd) ||
isSensitiveNoticeMissing(cd) ||
cd.SensitiveDataProcessing[7] === 1
}

const CONSENT_RULES = {
[ACTIVITY_SYNC_USER]: isConsentDenied,
[ACTIVITY_ENRICH_EIDS]: isConsentDenied,
[ACTIVITY_ENRICH_UFPD]: isTransmitUfpdConsentDenied,
[ACTIVITY_TRANSMIT_PRECISE_GEO]: isTransmitGeoConsentDenied
}

export function mspaRule(sids, getConsent, denies, applicableSids = () => gppDataHandler.getConsentData()?.applicableSections) {
return function() {
if (applicableSids().some(sid => sids.includes(sid))) {
const consent = getConsent();
if (consent == null) {
return {allow: false, reason: 'consent data not available'};
}
if (denies(consent)) {
return {allow: false}
}
}
}
}

export function setupRules(api, sids, normalizeConsent = (c) => c, rules = CONSENT_RULES, registerRule = registerActivityControl, getConsentData = () => gppDataHandler.getConsentData()) {
const unreg = [];
Object.entries(rules).forEach(([activity, denies]) => {
unreg.push(registerRule(activity, `MSPA (${api})`, mspaRule(sids, () => normalizeConsent(getConsentData()?.sectionData?.[api]), denies, () => getConsentData()?.applicableSections || [])))
})
return () => unreg.forEach(ur => ur())
}
5 changes: 3 additions & 2 deletions modules/categoryTranslation.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

import {config} from '../src/config.js';
import {hook, setupBeforeHookFnOnce} from '../src/hook.js';
import {hook, setupBeforeHookFnOnce, ready} from '../src/hook.js';
import {ajax} from '../src/ajax.js';
import {logError, timestamp} from '../src/utils.js';
import {addBidResponse} from '../src/auction.js';
Expand All @@ -32,7 +32,8 @@ export const registerAdserver = hook('async', function(adServer) {
initTranslation(url, DEFAULT_IAB_TO_FW_MAPPING_KEY);
}
}, 'registerAdserver');
registerAdserver();

ready.then(() => registerAdserver());

export const getAdserverCategoryHook = timedBidResponseHook('categoryTranslation', function getAdserverCategoryHook(fn, adUnitCode, bid, reject) {
if (!bid) {
Expand Down
98 changes: 12 additions & 86 deletions modules/consentManagement.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {timedAuctionHook} from '../src/utils/perfMetrics.js';
import {registerOrtbProcessor, REQUEST} from '../src/pbjsORTB.js';
import {enrichFPD} from '../src/fpd/enrichment.js';
import {getGlobal} from '../src/prebidGlobal.js';
import {cmpClient} from '../libraries/cmp/cmpClient.js';

const DEFAULT_CMP = 'iab';
const DEFAULT_CONSENT_TIMEOUT = 10000;
Expand Down Expand Up @@ -48,36 +49,6 @@ function lookupStaticConsentData({onSuccess, onError}) {
* @param {function(string, ...{}?)} cmpError acts as an error callback while interacting with CMP; pass along an error message (string) and any extra error arguments (purely for logging)
*/
function lookupIabConsent({onSuccess, onError, onEvent}) {
function findCMP() {
let f = window;
let cmpFrame;
let cmpFunction;
while (true) {
try {
if (typeof f.__tcfapi === 'function') {
cmpFunction = f.__tcfapi;
cmpFrame = f;
break;
}
} catch (e) { }

// need separate try/catch blocks due to the exception errors thrown when trying to check for a frame that doesn't exist in 3rd party env
try {
if (f.frames['__tcfapiLocator']) {
cmpFrame = f;
break;
}
} catch (e) { }

if (f === window.top) break;
f = f.parent;
}
return {
cmpFrame,
cmpFunction
};
}

function cmpResponseCallback(tcfData, success) {
logInfo('Received a response from CMP', tcfData);
if (success) {
Expand All @@ -90,70 +61,25 @@ function lookupIabConsent({onSuccess, onError, onEvent}) {
}
}

const cmpCallbacks = {};
const { cmpFrame, cmpFunction } = findCMP();
const cmp = cmpClient({
apiName: '__tcfapi',
apiVersion: CMP_VERSION,
apiArgs: ['command', 'version', 'callback', 'parameter'],
});

if (!cmpFrame) {
if (!cmp) {
return onError('TCF2 CMP not found.');
}
// to collect the consent information from the user, we perform two calls to the CMP in parallel:
// first to collect the user's consent choices represented in an encoded string (via getConsentData)
// second to collect the user's full unparsed consent information (via getVendorConsents)

// the following code also determines where the CMP is located and uses the proper workflow to communicate with it:
// check to see if CMP is found on the same window level as prebid and call it directly if so
// check to see if prebid is in a safeframe (with CMP support)
// else assume prebid may be inside an iframe and use the IAB CMP locator code to see if CMP's located in a higher parent window. this works in cross domain iframes
// if the CMP is not found, the iframe function will call the cmpError exit callback to abort the rest of the CMP workflow

if (typeof cmpFunction === 'function') {
if (cmp.isDirect) {
logInfo('Detected CMP API is directly accessible, calling it now...');
cmpFunction('addEventListener', CMP_VERSION, cmpResponseCallback);
} else {
logInfo('Detected CMP is outside the current iframe where Prebid.js is located, calling it now...');
callCmpWhileInIframe('addEventListener', cmpFrame, cmpResponseCallback);
}

function callCmpWhileInIframe(commandName, cmpFrame, moduleCallback) {
const apiName = '__tcfapi';

const callName = `${apiName}Call`;

/* Setup up a __cmp function to do the postMessage and stash the callback.
This function behaves (from the caller's perspective identicially to the in-frame __cmp call */
window[apiName] = function (cmd, cmpVersion, callback, arg) {
const callId = Math.random() + '';
const msg = {
[callName]: {
command: cmd,
version: cmpVersion,
parameter: arg,
callId: callId
}
};

cmpCallbacks[callId] = callback;
cmpFrame.postMessage(msg, '*');
}

/** when we get the return message, call the stashed callback */
window.addEventListener('message', readPostMessageResponse, false);

// call CMP
window[apiName](commandName, CMP_VERSION, moduleCallback);

function readPostMessageResponse(event) {
const cmpDataPkgName = `${apiName}Return`;
const json = (typeof event.data === 'string' && includes(event.data, cmpDataPkgName)) ? JSON.parse(event.data) : event.data;
if (json[cmpDataPkgName] && json[cmpDataPkgName].callId) {
const payload = json[cmpDataPkgName];
// TODO - clean up this logic (move listeners?); we have duplicate messages responses because 2 eventlisteners are active from the 2 cmp requests running in parallel
if (cmpCallbacks.hasOwnProperty(payload.callId)) {
cmpCallbacks[payload.callId](payload.returnValue, payload.success);
}
}
}
}
cmp({
command: 'addEventListener',
callback: cmpResponseCallback
})
}

/**
Expand Down
Loading

0 comments on commit 5e22f2f

Please sign in to comment.