UNPKG

@iabtechlabtcf/core

Version:

Ensures consistent encoding and decoding of TC Signals for the iab. Transparency and Consent Framework (TCF).

731 lines (730 loc) 29.1 kB
import { Cloneable } from './Cloneable.js'; import { GVLError } from './errors/index.js'; import { Json } from './Json.js'; import { ConsentLanguages } from './model/index.js'; /** * class with utilities for managing the global vendor list. Will use JSON to * fetch the vendor list from specified url and will serialize it into this * object and provide accessors. Provides ways to group vendors on the list by * purpose and feature. */ export class GVL extends Cloneable { static LANGUAGE_CACHE = new Map(); static CACHE = new Map(); static LATEST_CACHE_KEY = 0; static DEFAULT_LANGUAGE = 'EN'; /** * Set of available consent languages published by the IAB */ static consentLanguages = new ConsentLanguages(); static baseUrl_; /** * baseUrl - Entities using the vendor-list.json are required by the iab to * host their own copy of it to reduce the load on the iab's infrastructure * so a 'base' url must be set to be put together with the versioning scheme * of the filenames. * * @static * @param {string} url - the base url to load the vendor-list.json from. This is * broken out from the filename because it follows a different scheme for * latest file vs versioned files. * * @throws {GVLError} - If the url is http[s]://vendorlist.consensu.org/... * this will throw an error. IAB Europe requires that that CMPs and Vendors * cache their own copies of the GVL to minimize load on their * infrastructure. For more information regarding caching of the * vendor-list.json, please see [the TCF documentation on 'Caching the Global * Vendor List' * ](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20Consent%20string%20and%20vendor%20list%20formats%20v2.md#caching-the-global-vendor-list) */ static set baseUrl(url) { const notValid = /^https?:\/\/vendorlist\.consensu\.org\//; if (notValid.test(url)) { throw new GVLError('Invalid baseUrl! You may not pull directly from vendorlist.consensu.org and must provide your own cache'); } // if a trailing slash was forgotten if (url.length > 0 && url[url.length - 1] !== '/') { url += '/'; } this.baseUrl_ = url; } ; /** * baseUrl - Entities using the vendor-list.json are required by the iab to * host their own copy of it to reduce the load on the iab's infrastructure * so a 'base' url must be set to be put together with the versioning scheme * of the filenames. * * @static * @return {string} - returns the previously set baseUrl, the default is * `undefined` */ static get baseUrl() { return this.baseUrl_; } /** * @static * @param {string} - the latest is assumed to be vendor-list.json because * that is what the iab uses, but it could be different... if you want */ static latestFilename = 'vendor-list.json'; /** * @static * @param {string} - the versioned name is assumed to be * vendor-list-v[VERSION].json where [VERSION] will be replaced with the * specified version. But it could be different... if you want just make * sure to include the [VERSION] macro if you have a numbering scheme, it's a * simple string substitution. * * eg. * ```javascript * GVL.baseUrl = "http://www.mydomain.com/iabcmp/"; * GVL.versionedFilename = "vendorlist?getVersion=[VERSION]"; * ``` */ static versionedFilename = 'archives/vendor-list-v[VERSION].json'; /** * @param {string} - Translations of the names and descriptions for Purposes, * Special Purposes, Features, and Special Features to non-English languages * are contained in a file where attributes containing English content * (except vendor declaration information) are translated. The iab publishes * one following the scheme below where the LANG is the iso639-1 language * code. For a list of available translations * [please go here](https://register.consensu.org/Translation). * * eg. * ```javascript * GVL.baseUrl = "http://www.mydomain.com/iabcmp/"; * GVL.languageFilename = "purposes?getPurposes=[LANG]"; * ``` */ static languageFilename = 'purposes-[LANG].json'; /** * @param {Promise} resolved when this GVL object is populated with the data * or rejected if there is an error. */ readyPromise; /** * @param {number} gvlSpecificationVersion - schema version for the GVL that is used */ gvlSpecificationVersion; /** * @param {number} incremented with each published file change */ vendorListVersion; /** * @param {number} tcfPolicyVersion - The TCF MO will increment this value * whenever a GVL change (such as adding a new Purpose or Feature or a change * in Purpose wording) legally invalidates existing TC Strings and requires * CMPs to re-establish transparency and consent from users. If the policy * version number in the latest GVL is different from the value in your TC * String, then you need to re-establish transparency and consent for that * user. A version 1 format TC String is considered to have a version value * of 1. */ tcfPolicyVersion; /** * @param {string | Date} lastUpdated - the date in which the vendor list * json file was last updated. */ lastUpdated; /** * @param {IntMap<Purpose>} a collection of [[Purpose]]s */ purposes; /** * @param {IntMap<Purpose>} a collection of [[Purpose]]s */ specialPurposes; /** * @param {IntMap<Feature>} a collection of [[Feature]]s */ features; /** * @param {IntMap<Feature>} a collection of [[Feature]]s */ specialFeatures; /** * @param {boolean} internal reference of when the GVL is ready to be used */ isReady_ = false; /** * @param {IntMap<Vendor>} a collection of [[Vendor]]s */ vendors_; vendorIds; /** * @param {IntMap<Vendor>} a collection of [[Vendor]]. Used as a backup if a whitelist is sets */ fullVendorList; /** * @param {ByPurposeVendorMap} vendors by purpose */ byPurposeVendorMap; /** * @param {IDSetMap} vendors by special purpose */ bySpecialPurposeVendorMap; /** * @param {IDSetMap} vendors by feature */ byFeatureVendorMap; /** * @param {IDSetMap} vendors by special feature */ bySpecialFeatureVendorMap; /** * @param {IntMap<Stack>} a collection of [[Stack]]s */ stacks; /** * @param {IntMap<DataCategory>} a collection of [[DataCategory]]s */ dataCategories; lang_; cacheLang_; isLatest = false; /** * @param {VersionOrVendorList} [versionOrVendorList] - can be either a * [[VendorList]] object or a version number represented as a string or * number to download. If nothing is passed the latest version of the GVL * will be loaded * @param {GvlCreationOptions} [options] - it is an optional object where the default language can be set */ constructor(versionOrVendorList, options) { super(); /** * should have been configured before and instance was created and will * persist through the app */ let url = GVL.baseUrl; let parsedLanguage = options?.language; if (parsedLanguage) { try { parsedLanguage = GVL.consentLanguages.parseLanguage(parsedLanguage); } catch (e) { throw new GVLError('Error during parsing the language: ' + e.message); } } this.lang_ = parsedLanguage || GVL.DEFAULT_LANGUAGE; this.cacheLang_ = parsedLanguage || GVL.DEFAULT_LANGUAGE; if (this.isVendorList(versionOrVendorList)) { this.populate(versionOrVendorList); this.readyPromise = Promise.resolve(); } else { if (!url) { throw new GVLError('must specify GVL.baseUrl before loading GVL json'); } if (versionOrVendorList > 0) { const version = versionOrVendorList; if (GVL.CACHE.has(version)) { this.populate(GVL.CACHE.get(version)); this.readyPromise = Promise.resolve(); } else { // load version specified url += GVL.versionedFilename.replace('[VERSION]', String(version)); this.readyPromise = this.fetchJson(url); } } else { /** * whatever it is (or isn't)... it doesn't matter we'll just get the * latest. In this case we may have cached the latest version at key 0. * If we have then we'll just use that instead of making a request. * Otherwise we'll have to load it (and then we'll cache it for next * time) */ if (GVL.CACHE.has(GVL.LATEST_CACHE_KEY)) { this.populate(GVL.CACHE.get(GVL.LATEST_CACHE_KEY)); this.readyPromise = Promise.resolve(); } else { this.isLatest = true; this.readyPromise = this.fetchJson(url + GVL.latestFilename); } } } } /** * emptyLanguageCache * * @param {string} [lang] - Optional language code to remove from * the cache. Should be one of the languages in GVL.consentLanguages set. * If not then the whole cache will be deleted. * @return {boolean} - true if anything was deleted from the cache */ static emptyLanguageCache(lang) { let result = false; if (lang == null && GVL.LANGUAGE_CACHE.size > 0) { GVL.LANGUAGE_CACHE = new Map(); result = true; } else if (typeof lang === 'string' && this.consentLanguages.has(lang.toUpperCase())) { GVL.LANGUAGE_CACHE.delete(lang.toUpperCase()); result = true; } return result; } /** * emptyCache * * @param {number} [vendorListVersion] - version of the vendor list to delete * from the cache. If none is specified then the whole cache is deleted. * @return {boolean} - true if anything was deleted from the cache */ static emptyCache(vendorListVersion) { let retr = false; if (Number.isInteger(vendorListVersion) && vendorListVersion >= 0) { GVL.CACHE.delete(vendorListVersion); retr = true; } else if (vendorListVersion === undefined) { GVL.CACHE = new Map(); retr = true; } return retr; } cacheLanguage() { if (!GVL.LANGUAGE_CACHE.has(this.cacheLang_)) { GVL.LANGUAGE_CACHE.set(this.cacheLang_, { purposes: this.purposes, specialPurposes: this.specialPurposes, features: this.features, specialFeatures: this.specialFeatures, stacks: this.stacks, dataCategories: this.dataCategories, }); } } async fetchJson(url) { try { this.populate(await Json.fetch(url)); } catch (err) { throw new GVLError(err.message); } } /** * getJson - Method for getting the JSON that was downloaded to created this * `GVL` object * * @return {VendorList} - The basic JSON structure without the extra * functionality and methods of this class. */ getJson() { return { gvlSpecificationVersion: this.gvlSpecificationVersion, vendorListVersion: this.vendorListVersion, tcfPolicyVersion: this.tcfPolicyVersion, lastUpdated: this.lastUpdated, purposes: this.clonePurposes(), specialPurposes: this.cloneSpecialPurposes(), features: this.cloneFeatures(), specialFeatures: this.cloneSpecialFeatures(), stacks: this.cloneStacks(), ...(this.dataCategories ? { dataCategories: this.cloneDataCategories() } : {}), vendors: this.cloneVendors(), }; } cloneSpecialFeatures() { const features = {}; for (const featureId of Object.keys(this.specialFeatures)) { features[featureId] = GVL.cloneFeature(this.specialFeatures[featureId]); } return features; } cloneFeatures() { const features = {}; for (const featureId of Object.keys(this.features)) { features[featureId] = GVL.cloneFeature(this.features[featureId]); } return features; } cloneStacks() { const stacks = {}; for (const stackId of Object.keys(this.stacks)) { stacks[stackId] = GVL.cloneStack(this.stacks[stackId]); } return stacks; } cloneDataCategories() { const dataCategories = {}; for (const dataCategoryId of Object.keys(this.dataCategories)) { dataCategories[dataCategoryId] = GVL.cloneDataCategory(this.dataCategories[dataCategoryId]); } return dataCategories; } cloneSpecialPurposes() { const purposes = {}; for (const purposeId of Object.keys(this.specialPurposes)) { purposes[purposeId] = GVL.clonePurpose(this.specialPurposes[purposeId]); } return purposes; } clonePurposes() { const purposes = {}; for (const purposeId of Object.keys(this.purposes)) { purposes[purposeId] = GVL.clonePurpose(this.purposes[purposeId]); } return purposes; } static clonePurpose(purpose) { return { id: purpose.id, name: purpose.name, description: purpose.description, ...(purpose.descriptionLegal ? { descriptionLegal: purpose.descriptionLegal } : {}), ...(purpose.illustrations ? { illustrations: Array.from(purpose.illustrations) } : {}), }; } static cloneFeature(feature) { return { id: feature.id, name: feature.name, description: feature.description, ...(feature.descriptionLegal ? { descriptionLegal: feature.descriptionLegal } : {}), ...(feature.illustrations ? { illustrations: Array.from(feature.illustrations) } : {}), }; } static cloneDataCategory(dataCategory) { return { id: dataCategory.id, name: dataCategory.name, description: dataCategory.description, }; } static cloneStack(stack) { return { id: stack.id, name: stack.name, description: stack.description, purposes: Array.from(stack.purposes), specialFeatures: Array.from(stack.specialFeatures), }; } static cloneDataRetention(dataRetention) { return { ...(typeof dataRetention.stdRetention === 'number' ? { stdRetention: dataRetention.stdRetention } : {}), purposes: { ...dataRetention.purposes }, specialPurposes: { ...dataRetention.specialPurposes }, }; } static cloneVendorUrls(urls) { return urls.map((url) => ({ langId: url.langId, privacy: url.privacy, ...(url.legIntClaim ? { legIntClaim: url.legIntClaim } : {}), })); } static cloneVendor(vendor) { return { id: vendor.id, name: vendor.name, purposes: Array.from(vendor.purposes), legIntPurposes: Array.from(vendor.legIntPurposes), flexiblePurposes: Array.from(vendor.flexiblePurposes), specialPurposes: Array.from(vendor.specialPurposes), features: Array.from(vendor.features), specialFeatures: Array.from(vendor.specialFeatures), ...(vendor.overflow ? { overflow: { httpGetLimit: vendor.overflow.httpGetLimit } } : {}), ...(typeof vendor.cookieMaxAgeSeconds === 'number' || vendor.cookieMaxAgeSeconds === null ? { cookieMaxAgeSeconds: vendor.cookieMaxAgeSeconds } : {}), ...(vendor.usesCookies !== undefined ? { usesCookies: vendor.usesCookies } : {}), ...(vendor.policyUrl ? { policyUrl: vendor.policyUrl } : {}), ...(vendor.cookieRefresh !== undefined ? { cookieRefresh: vendor.cookieRefresh } : {}), ...(vendor.usesNonCookieAccess !== undefined ? { usesNonCookieAccess: vendor.usesNonCookieAccess } : {}), ...(vendor.dataRetention ? { dataRetention: this.cloneDataRetention(vendor.dataRetention) } : {}), ...(vendor.urls ? { urls: this.cloneVendorUrls(vendor.urls) } : {}), ...(vendor.dataDeclaration ? { dataDeclaration: Array.from(vendor.dataDeclaration) } : {}), ...(vendor.deviceStorageDisclosureUrl ? { deviceStorageDisclosureUrl: vendor.deviceStorageDisclosureUrl } : {}), ...(vendor.deletedDate ? { deletedDate: vendor.deletedDate } : {}), }; } cloneVendors() { const vendors = {}; for (const vendorId of Object.keys(this.fullVendorList)) { vendors[vendorId] = GVL.cloneVendor(this.fullVendorList[vendorId]); } return vendors; } /** * changeLanguage - retrieves the purpose language translation and sets the * internal language variable * * @param {string} lang - language code to change language to * @return {Promise<void | GVLError>} - returns the `readyPromise` and * resolves when this GVL is populated with the data from the language file. */ async changeLanguage(lang) { let parsedLanguage = lang; try { parsedLanguage = GVL.consentLanguages.parseLanguage(lang); } catch (e) { throw new GVLError('Error during parsing the language: ' + e.message); } const cacheLang = lang.toUpperCase(); // Default language EN can be loaded only by default GVL if (parsedLanguage.toLowerCase() === GVL.DEFAULT_LANGUAGE.toLowerCase() && !GVL.LANGUAGE_CACHE.has(cacheLang)) { return; } if (parsedLanguage !== this.lang_) { this.lang_ = parsedLanguage; if (GVL.LANGUAGE_CACHE.has(cacheLang)) { const cached = GVL.LANGUAGE_CACHE.get(cacheLang); for (const prop in cached) { if (cached.hasOwnProperty(prop)) { this[prop] = cached[prop]; } } } else { // load Language specified const url = GVL.baseUrl + GVL.languageFilename.replace('[LANG]', this.lang_.toLowerCase()); try { await this.fetchJson(url); this.cacheLang_ = cacheLang; this.cacheLanguage(); } catch (err) { throw new GVLError('unable to load language: ' + err.message); } } } } get language() { return this.lang_; } isVendorList(gvlObject) { return gvlObject !== undefined && gvlObject.vendors !== undefined; } populate(gvlObject) { /** * these are populated regardless of whether it's a Declarations file or * a VendorList */ this.purposes = gvlObject.purposes; this.specialPurposes = gvlObject.specialPurposes; this.features = gvlObject.features; this.specialFeatures = gvlObject.specialFeatures; this.stacks = gvlObject.stacks; this.dataCategories = gvlObject.dataCategories; if (this.isVendorList(gvlObject)) { this.gvlSpecificationVersion = gvlObject.gvlSpecificationVersion; this.tcfPolicyVersion = gvlObject.tcfPolicyVersion; this.vendorListVersion = gvlObject.vendorListVersion; this.lastUpdated = gvlObject.lastUpdated; if (typeof this.lastUpdated === 'string') { this.lastUpdated = new Date(this.lastUpdated); } this.vendors_ = gvlObject.vendors; this.fullVendorList = gvlObject.vendors; this.mapVendors(); this.isReady_ = true; if (this.isLatest) { /** * If the "LATEST" was requested then this flag will be set to true. * In that case we'll cache the GVL at the special key */ GVL.CACHE.set(GVL.LATEST_CACHE_KEY, this.getJson()); } /** * Whether or not it's the "LATEST" we'll cache the gvl at the version it * is declared to be (if it's not already). to avoid downloading it again * in the future. */ if (!GVL.CACHE.has(this.vendorListVersion)) { GVL.CACHE.set(this.vendorListVersion, this.getJson()); } } this.cacheLanguage(); } mapVendors(vendorIds) { // create new instances of the maps this.byPurposeVendorMap = {}; this.bySpecialPurposeVendorMap = {}; this.byFeatureVendorMap = {}; this.bySpecialFeatureVendorMap = {}; // initializes data structure for purpose map Object.keys(this.purposes).forEach((purposeId) => { this.byPurposeVendorMap[purposeId] = { legInt: new Set(), consent: new Set(), flexible: new Set(), }; }); // initializes data structure for special purpose map Object.keys(this.specialPurposes).forEach((purposeId) => { this.bySpecialPurposeVendorMap[purposeId] = new Set(); }); // initializes data structure for feature map Object.keys(this.features).forEach((featureId) => { this.byFeatureVendorMap[featureId] = new Set(); }); // initializes data structure for feature map Object.keys(this.specialFeatures).forEach((featureId) => { this.bySpecialFeatureVendorMap[featureId] = new Set(); }); if (!Array.isArray(vendorIds)) { vendorIds = Object.keys(this.fullVendorList).map((vId) => +vId); } this.vendorIds = new Set(vendorIds); // assigns vendor ids to their respective maps this.vendors_ = vendorIds.reduce((vendors, vendorId) => { const vendor = this.vendors_[String(vendorId)]; if (vendor && vendor.deletedDate === undefined) { vendor.purposes.forEach((purposeId) => { const purpGroup = this.byPurposeVendorMap[String(purposeId)]; purpGroup.consent.add(vendorId); }); vendor.specialPurposes.forEach((purposeId) => { this.bySpecialPurposeVendorMap[String(purposeId)].add(vendorId); }); vendor.legIntPurposes.forEach((purposeId) => { this.byPurposeVendorMap[String(purposeId)].legInt.add(vendorId); }); // could not be there if (vendor.flexiblePurposes) { vendor.flexiblePurposes.forEach((purposeId) => { this.byPurposeVendorMap[String(purposeId)].flexible.add(vendorId); }); } vendor.features.forEach((featureId) => { this.byFeatureVendorMap[String(featureId)].add(vendorId); }); vendor.specialFeatures.forEach((featureId) => { this.bySpecialFeatureVendorMap[String(featureId)].add(vendorId); }); vendors[vendorId] = vendor; } return vendors; }, {}); } getFilteredVendors(purposeOrFeature, id, subType, special) { const properPurposeOrFeature = purposeOrFeature.charAt(0).toUpperCase() + purposeOrFeature.slice(1); let vendorSet; const retr = {}; if (purposeOrFeature === 'purpose' && subType) { vendorSet = this['by' + properPurposeOrFeature + 'VendorMap'][String(id)][subType]; } else { vendorSet = this['by' + (special ? 'Special' : '') + properPurposeOrFeature + 'VendorMap'][String(id)]; } vendorSet.forEach((vendorId) => { retr[String(vendorId)] = this.vendors[String(vendorId)]; }); return retr; } /** * getVendorsWithConsentPurpose * * @param {number} purposeId * @return {IntMap<Vendor>} - list of vendors that have declared the consent purpose id */ getVendorsWithConsentPurpose(purposeId) { return this.getFilteredVendors('purpose', purposeId, 'consent'); } /** * getVendorsWithLegIntPurpose * * @param {number} purposeId * @return {IntMap<Vendor>} - list of vendors that have declared the legInt (Legitimate Interest) purpose id */ getVendorsWithLegIntPurpose(purposeId) { return this.getFilteredVendors('purpose', purposeId, 'legInt'); } /** * getVendorsWithFlexiblePurpose * * @param {number} purposeId * @return {IntMap<Vendor>} - list of vendors that have declared the flexible purpose id */ getVendorsWithFlexiblePurpose(purposeId) { return this.getFilteredVendors('purpose', purposeId, 'flexible'); } /** * getVendorsWithSpecialPurpose * * @param {number} specialPurposeId * @return {IntMap<Vendor>} - list of vendors that have declared the special purpose id */ getVendorsWithSpecialPurpose(specialPurposeId) { return this.getFilteredVendors('purpose', specialPurposeId, undefined, true); } /** * getVendorsWithFeature * * @param {number} featureId * @return {IntMap<Vendor>} - list of vendors that have declared the feature id */ getVendorsWithFeature(featureId) { return this.getFilteredVendors('feature', featureId); } /** * getVendorsWithSpecialFeature * * @param {number} specialFeatureId * @return {IntMap<Vendor>} - list of vendors that have declared the special feature id */ getVendorsWithSpecialFeature(specialFeatureId) { return this.getFilteredVendors('feature', specialFeatureId, undefined, true); } /** * vendors * * @return {IntMap<Vendor>} - the list of vendors as it would on the JSON file * except if `narrowVendorsTo` was called, it would be that narrowed list */ get vendors() { return this.vendors_; } /** * narrowVendorsTo - narrows vendors represented in this GVL to the list of ids passed in * * @param {number[]} vendorIds - list of ids to narrow this GVL to * @return {void} */ narrowVendorsTo(vendorIds) { this.mapVendors(vendorIds); } /** * isReady - Whether or not this instance is ready to be used. This will be * immediately and synchronously true if a vendorlist object is passed into * the constructor or once the JSON vendorllist is retrieved. * * @return {boolean} whether or not the instance is ready to be interacted * with and all the data is populated */ get isReady() { return this.isReady_; } /** * clone - overrides base `clone()` method since GVL is a special class that * represents a JSON structure with some additional functionality. * * @return {GVL} */ clone() { const result = new GVL(this.getJson()); /* * If the current language of the GVL is not the default language, we set the language of * the clone to the current language since a new GVL is always created with the default * language. */ if (this.lang_ !== GVL.DEFAULT_LANGUAGE) { /* * Since the GVL language was changed, this means that an asynchronous changeLanguage * call was made prior to cloning the GVL. The new language specified has been cached * by the GVL and this changeLanguage call made as a part of cloning the GVL will be * synchronous. The code will look for the language definitions in the cache instead * of creating a http request. */ result.changeLanguage(this.lang_); } return result; } static isInstanceOf(questionableInstance) { const isSo = typeof questionableInstance === 'object'; return (isSo && typeof questionableInstance.narrowVendorsTo === 'function'); } }