diff --git a/lib/config.test.js b/lib/config.test.js index 349d119..fc7a757 100644 --- a/lib/config.test.js +++ b/lib/config.test.js @@ -19,7 +19,7 @@ describe("getConfig", () => { site: "site", cookies: false, initPassport: false, - consent: { deviceAccess: true, reg: "us" }, + consent: { static: { deviceAccess: true, reg: "us" } }, }) ).toEqual({ host: "host", @@ -30,12 +30,20 @@ describe("getConfig", () => { }); }); - it("resolves to globalConsent when using auto", () => { + it("infers regulation and gathers consent when using cmpapi", () => { + const spy = jest.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({ + resolvedOptions: () => ({ + timeZone: "America/New_York", + }), + })); + const config = getConfig({ host: "host", site: "site", - consent: "auto", + consent: { cmpapi: {} }, }); - expect(config.consent).toEqual(globalConsent); + expect(config.consent).toEqual({ deviceAccess: true, reg: "us" }); + + spy.mockRestore(); }); }); diff --git a/lib/config.ts b/lib/config.ts index 5ec0f12..0fc3bbb 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,28 +1,34 @@ -import globalConsent, { Consent } from "./core/regs/consent"; +import { getConsent, Consent, inferRegulation } from "./core/regs/consent"; + +type CMPApiConfig = { + tcfeuVendorID?: number; +}; + +type InitConsent = { + cmpapi?: CMPApiConfig; + static?: Consent; +}; type InitConfig = { host: string; site: string; cookies?: boolean; initPassport?: boolean; - consent?: Consent | "auto"; + consent?: InitConsent; }; -type ResolvedConfig = Required & { +type ResolvedConfig = Required> & { consent: Consent; }; const DCN_DEFAULTS = { cookies: true, initPassport: true, - // Backwards compatibility, default to device access allowed - // Once we have measured the impact of automatic CMP integration in the wild - // we may move to "auto" default. - consent: { deviceAccess: true, reg: null } as Consent, + consent: { deviceAccess: true, reg: null }, }; function getConfig(init: InitConfig): ResolvedConfig { - const config = { + const config: ResolvedConfig = { host: init.host, site: init.site, cookies: init.cookies ?? DCN_DEFAULTS.cookies, @@ -30,10 +36,14 @@ function getConfig(init: InitConfig): ResolvedConfig { consent: DCN_DEFAULTS.consent, }; - if (init.consent) { - config.consent = init.consent === "auto" ? globalConsent : init.consent; + if (init.consent?.static) { + config.consent = init.consent.static; + } else if (init.consent?.cmpapi) { + config.consent = getConsent(inferRegulation(), init.consent.cmpapi); } + return config; } -export { InitConfig, ResolvedConfig, getConfig }; +export type { InitConsent, CMPApiConfig, InitConfig, ResolvedConfig }; +export { getConfig }; diff --git a/lib/core/regs/consent.test.js b/lib/core/regs/consent.test.js index 6ac3b5c..3ef83fb 100644 --- a/lib/core/regs/consent.test.js +++ b/lib/core/regs/consent.test.js @@ -100,7 +100,7 @@ describe("getConsent", () => { it("updates consent based on gpp signals for gdpr", () => { const signal = mockGPPSignal(windowSpy); - const consent = getConsent("gdpr"); + let consent = getConsent("gdpr"); // By default device access is denied expect(consent.deviceAccess).toBe(false); expect(consent.reg).toBe("gdpr"); @@ -177,6 +177,39 @@ describe("getConsent", () => { expect(consent.deviceAccess).toBe(false); expect(consent.gpp).toBe("revoked"); expect(consent.gppSectionIDs).toEqual([gpp.tcfeuv2.SectionID]); + + // Given a specific vendor ID + consent = getConsent("gdpr", { tcfeuVendorID: 42 }); + signal({ + parsedSections: { + [gpp.tcfeuv2.APIPrefix]: [ + // Core segment + { Version: 2, PurposeConsent: [1], VendorConsent: [42] }, + { SegmentType: 3, PubPurposesConsent: [] }, + ], + }, + applicableSections: [gpp.tcfeuv2.SectionID], + gppString: "vendor42", + }); + expect(consent.deviceAccess).toBe(true); + expect(consent.gpp).toBe("vendor42"); + expect(consent.gppSectionIDs).toEqual([gpp.tcfeuv2.SectionID]); + + signal({ + parsedSections: { + [gpp.tcfeuv2.APIPrefix]: [ + // Core segment + { Version: 2, PurposeConsent: [1], VendorConsent: [] }, + { SegmentType: 3, PubPurposesConsent: [1] }, + ], + }, + applicableSections: [gpp.tcfeuv2.SectionID], + gppString: "vendor42notgranted", + }); + + expect(consent.deviceAccess).toBe(false); + expect(consent.gpp).toBe("vendor42notgranted"); + expect(consent.gppSectionIDs).toEqual([gpp.tcfeuv2.SectionID]); }); it("updates consent based on gpp signals for can", () => { diff --git a/lib/core/regs/consent.ts b/lib/core/regs/consent.ts index 42c6448..78af9bc 100644 --- a/lib/core/regs/consent.ts +++ b/lib/core/regs/consent.ts @@ -1,12 +1,19 @@ -import { inferRegulation, Regulation } from "./regulations"; +import type { CMPApiConfig } from "config"; +import type { Regulation } from "./regulations"; +import { inferRegulation } from "./regulations"; import * as gpp from "./gpp"; import * as tcf from "./tcf"; type Consent = { + // Whether the device access is granted deviceAccess: boolean; + // The regulation that was detected, null if unknown reg: Regulation | null; + // The TCF string if available, only when reg is "gdpr" tcf?: string; + // The GPP string if available gpp?: string; + // The GPP section IDs that are applicable gppSectionIDs?: number[]; }; @@ -33,19 +40,19 @@ const usSectionIDs = [ gpp.usva.SectionID, ]; -function gdprConsent(): Consent { +function gdprConsent(tcfeuVendorID?: number): Consent { const consent: Consent = { deviceAccess: false, reg: "gdpr" }; // Use TCF if available, otherwise use GPP, // if none available assume device access is not allowed if (hasTCF()) { onTCFChange((data) => { - consent.deviceAccess = tcfDeviceAccess(data); + consent.deviceAccess = tcfDeviceAccess(data, tcfeuVendorID); consent.tcf = data.tcString; }); } else if (hasGPP()) { onGPPSectionChange(gdprSectionIDs, (data) => { - consent.deviceAccess = gppEUDeviceAccess(data); + consent.deviceAccess = gppEUDeviceAccess(data, tcfeuVendorID); consent.gpp = data.gppString; consent.gppSectionIDs = data.applicableSections; }); @@ -74,10 +81,10 @@ function canConsent(): Consent { return consent; } -function getConsent(reg: Regulation | null): Consent { +function getConsent(reg: Regulation | null, conf: CMPApiConfig = {}): Consent { switch (reg) { case "gdpr": - return gdprConsent(); + return gdprConsent(conf.tcfeuVendorID); case "us": return usConsent(); case "can": @@ -87,12 +94,24 @@ function getConsent(reg: Regulation | null): Consent { } } -function gppEUDeviceAccess(data: gpp.cmpapi.PingReturn): boolean { +function gppEUDeviceAccess(data: gpp.cmpapi.PingReturn, vendorID?: number): boolean { if (!(gpp.tcfeuv2.APIPrefix in data.parsedSections)) { return false; } const section = data.parsedSections[gpp.tcfeuv2.APIPrefix] || []; + + if (typeof vendorID === "number") { + const coreSegment = section.find((s) => { + return "Version" in s; + }); + if (!coreSegment) { + return false; + } + + return coreSegment.PurposeConsent.includes(1) && coreSegment.VendorConsent.includes(vendorID); + } + const publisherSubsection = section.find((s) => { return "SegmentType" in s && s.SegmentType === 3; }); @@ -104,10 +123,15 @@ function gppEUDeviceAccess(data: gpp.cmpapi.PingReturn): boolean { return publisherSubsection.PubPurposesConsent.includes(1); } -function tcfDeviceAccess(data: tcf.cmpapi.TCData): boolean { +function tcfDeviceAccess(data: tcf.cmpapi.TCData, vendorID?: number): boolean { if (!data.gdprApplies) { return true; } + + if (vendorID) { + return data.purpose.consents["1"] && data.vendor.consents[vendorID]; + } + return !!data.publisher.consents["1"]; } @@ -162,5 +186,5 @@ function hasTCF(): boolean { return typeof window.__tcfapi === "function"; } -export default getConsent(inferRegulation()); -export { Consent, getConsent }; +export { getConsent, inferRegulation }; +export type { Consent }; diff --git a/lib/core/regs/regulations.ts b/lib/core/regs/regulations.ts index cd1461a..bc2cb41 100644 --- a/lib/core/regs/regulations.ts +++ b/lib/core/regs/regulations.ts @@ -72,4 +72,5 @@ function inferRegulation(): Regulation | null { return regulation ?? null; } -export { inferRegulation, Regulation }; +export { inferRegulation }; +export type { Regulation };