@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
JavaScript
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');
}
}