From cfc8e083431a75cab505c4b43fd325aeb6a4592e Mon Sep 17 00:00:00 2001 From: Antoine Niek Date: Tue, 7 Jan 2025 14:09:29 -0500 Subject: [PATCH] Add GPP/TCF cmpapi integration to respect device access in EU/CA/US (#152) --- demos/react/src/identify.tsx | 4 +- lib/config.test.js | 49 +++++ lib/config.ts | 45 +++- lib/core/network.test.js | 23 +- lib/core/network.ts | 22 +- lib/core/regs/consent.test.js | 342 ++++++++++++++++++++++++++++++ lib/core/regs/consent.ts | 135 ++++++++++++ lib/core/regs/gpp/cmpapi.ts | 52 +++++ lib/core/regs/gpp/index.ts | 2 + lib/core/regs/gpp/primitives.ts | 2 + lib/core/regs/gpp/sections.ts | 18 ++ lib/core/regs/gpp/tcfcav1.ts | 43 ++++ lib/core/regs/gpp/tcfeuv2.ts | 46 ++++ lib/core/regs/gpp/usca.ts | 23 ++ lib/core/regs/gpp/usco.ts | 22 ++ lib/core/regs/gpp/usct.ts | 22 ++ lib/core/regs/gpp/usde.ts | 23 ++ lib/core/regs/gpp/usfl.ts | 23 ++ lib/core/regs/gpp/usia.ts | 23 ++ lib/core/regs/gpp/usmt.ts | 23 ++ lib/core/regs/gpp/usnat.ts | 27 +++ lib/core/regs/gpp/usne.ts | 23 ++ lib/core/regs/gpp/usnh.ts | 23 ++ lib/core/regs/gpp/usnj.ts | 23 ++ lib/core/regs/gpp/usor.ts | 23 ++ lib/core/regs/gpp/ustn.ts | 23 ++ lib/core/regs/gpp/ustx.ts | 23 ++ lib/core/regs/gpp/usut.ts | 22 ++ lib/core/regs/gpp/usva.ts | 22 ++ lib/core/regs/regulations.test.js | 47 ++++ lib/core/regs/regulations.ts | 76 +++++++ lib/core/regs/storage.test.js | 52 +++++ lib/core/regs/storage.ts | 34 +++ lib/core/regs/tcf/cmpapi.ts | 64 ++++++ lib/core/regs/tcf/index.ts | 1 + lib/core/storage.test.js | 6 +- lib/core/storage.ts | 30 +-- lib/edge/identify.ts | 4 +- lib/edge/profile.ts | 4 +- lib/edge/site.ts | 6 +- lib/edge/targeting.ts | 8 +- lib/edge/tokenize.ts | 4 +- lib/edge/uid2_token.ts | 4 +- lib/edge/witness.ts | 4 +- lib/sdk.ts | 8 +- package-lock.json | 263 ++++++++++++----------- package.json | 2 +- 47 files changed, 1600 insertions(+), 168 deletions(-) create mode 100644 lib/config.test.js create mode 100644 lib/core/regs/consent.test.js create mode 100644 lib/core/regs/consent.ts create mode 100644 lib/core/regs/gpp/cmpapi.ts create mode 100644 lib/core/regs/gpp/index.ts create mode 100644 lib/core/regs/gpp/primitives.ts create mode 100644 lib/core/regs/gpp/sections.ts create mode 100644 lib/core/regs/gpp/tcfcav1.ts create mode 100644 lib/core/regs/gpp/tcfeuv2.ts create mode 100644 lib/core/regs/gpp/usca.ts create mode 100644 lib/core/regs/gpp/usco.ts create mode 100644 lib/core/regs/gpp/usct.ts create mode 100644 lib/core/regs/gpp/usde.ts create mode 100644 lib/core/regs/gpp/usfl.ts create mode 100644 lib/core/regs/gpp/usia.ts create mode 100644 lib/core/regs/gpp/usmt.ts create mode 100644 lib/core/regs/gpp/usnat.ts create mode 100644 lib/core/regs/gpp/usne.ts create mode 100644 lib/core/regs/gpp/usnh.ts create mode 100644 lib/core/regs/gpp/usnj.ts create mode 100644 lib/core/regs/gpp/usor.ts create mode 100644 lib/core/regs/gpp/ustn.ts create mode 100644 lib/core/regs/gpp/ustx.ts create mode 100644 lib/core/regs/gpp/usut.ts create mode 100644 lib/core/regs/gpp/usva.ts create mode 100644 lib/core/regs/regulations.test.js create mode 100644 lib/core/regs/regulations.ts create mode 100644 lib/core/regs/storage.test.js create mode 100644 lib/core/regs/storage.ts create mode 100644 lib/core/regs/tcf/cmpapi.ts create mode 100644 lib/core/regs/tcf/index.ts diff --git a/demos/react/src/identify.tsx b/demos/react/src/identify.tsx index 285bf888..ebad73ea 100644 --- a/demos/react/src/identify.tsx +++ b/demos/react/src/identify.tsx @@ -1,12 +1,12 @@ import React, { useContext, createContext, useState } from "react"; import ReactDOM from "react-dom"; -import OptableSDK, { OptableConfig } from "@optable/web-sdk"; +import OptableSDK, { InitConfig } from "@optable/web-sdk"; const OptableContext = createContext(null); // Sandbox configuration injected by webpack based on build environment (see demos/react/webpack.config.js) declare global { - const DCN_CONFIG: OptableConfig; + const DCN_CONFIG: InitConfig; } // Provide a global SDK instance across the application diff --git a/lib/config.test.js b/lib/config.test.js new file mode 100644 index 00000000..fc7a7573 --- /dev/null +++ b/lib/config.test.js @@ -0,0 +1,49 @@ +import { getConfig } from "./config"; +import globalConsent from "./core/regs/consent"; + +describe("getConfig", () => { + it("returns the default config when no overrides are provided", () => { + expect(getConfig({ host: "host", site: "site" })).toEqual({ + host: "host", + site: "site", + cookies: true, + initPassport: true, + consent: { deviceAccess: true, reg: null }, + }); + }); + + it("allows overriding all properties", () => { + expect( + getConfig({ + host: "host", + site: "site", + cookies: false, + initPassport: false, + consent: { static: { deviceAccess: true, reg: "us" } }, + }) + ).toEqual({ + host: "host", + site: "site", + cookies: false, + initPassport: false, + consent: { deviceAccess: true, reg: "us" }, + }); + }); + + 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: { cmpapi: {} }, + }); + expect(config.consent).toEqual({ deviceAccess: true, reg: "us" }); + + spy.mockRestore(); + }); +}); diff --git a/lib/config.ts b/lib/config.ts index 67a99a6e..429d7c07 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,18 +1,53 @@ -type OptableConfig = { +import { getConsent, Consent, inferRegulation } from "./core/regs/consent"; + +type CMPApiConfig = { + // An optional vendor ID from GVL (global vendor list) when interpretting TCF/GPP EU consent, + // when not passed, defaults to publisher consent. + tcfeuVendorID?: number; +}; + +type InitConsent = { + // A "cmpapi" configuration indicating that consent should be gathered from CMP apis. + cmpapi?: CMPApiConfig; + // A "static" consent object already collected by the publisher + static?: Consent; +}; + +type InitConfig = { host: string; site: string; cookies?: boolean; initPassport?: boolean; + consent?: InitConsent; +}; + +type ResolvedConfig = Required> & { + consent: Consent; }; const DCN_DEFAULTS = { cookies: true, initPassport: true, + consent: { deviceAccess: true, reg: null }, }; -function getConfig(config: OptableConfig): Required { - return { ...DCN_DEFAULTS, ...config }; +function getConfig(init: InitConfig): ResolvedConfig { + const config: ResolvedConfig = { + host: init.host, + site: init.site, + cookies: init.cookies ?? DCN_DEFAULTS.cookies, + initPassport: init.initPassport ?? DCN_DEFAULTS.initPassport, + consent: DCN_DEFAULTS.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 { OptableConfig, getConfig }; -export default OptableConfig; +export type { InitConsent, CMPApiConfig, InitConfig, ResolvedConfig }; +export { getConfig }; diff --git a/lib/core/network.test.js b/lib/core/network.test.js index dc3d0f43..ef37fcf7 100644 --- a/lib/core/network.test.js +++ b/lib/core/network.test.js @@ -2,11 +2,28 @@ import { buildRequest } from "./network"; import { default as buildInfo } from "../build.json"; describe("buildRequest", () => { - test("preserves path query string", () => { - const dcn = { cookies: true, host: "host", site: "site" }; + it("preserves path query string", () => { + const dcn = { + cookies: true, + host: "host", + site: "site", + consent: { reg: "can", gpp: "gpp", gppSectionIDs: [1, 2] }, + }; + const req = { method: "GET" }; const request = buildRequest("/path?query=string", dcn, req); - expect(request.url).toBe(`https://host/site/path?query=string&osdk=web-${buildInfo.version}&cookies=yes`); + const url = new URL(request.url); + expect(url.host).toBe("host"); + expect(url.protocol).toBe("https:"); + expect(url.pathname).toBe("/site/path"); + expect([...url.searchParams.entries()]).toEqual([ + ["query", "string"], + ["osdk", `web-${buildInfo.version}`], + ["reg", "can"], + ["gpp", "gpp"], + ["gpp_sid", "1,2"], + ["cookies", "yes"], + ]); }); }); diff --git a/lib/core/network.ts b/lib/core/network.ts index 3a0bc8f3..7cd6b2a0 100644 --- a/lib/core/network.ts +++ b/lib/core/network.ts @@ -1,13 +1,29 @@ -import type { OptableConfig } from "../config"; +import type { ResolvedConfig } from "../config"; import { default as buildInfo } from "../build.json"; import { LocalStorage } from "./storage"; -function buildRequest(path: string, config: Required, init?: RequestInit): Request { +function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): Request { const { site, host, cookies } = config; const url = new URL(`${site}${path}`, `https://${host}`); url.searchParams.set("osdk", `web-${buildInfo.version}`); + if (config.consent.reg) { + url.searchParams.set("reg", config.consent.reg); + } + + if (config.consent.gpp) { + url.searchParams.set("gpp", config.consent.gpp); + } + + if (config.consent.gppSectionIDs) { + url.searchParams.set("gpp_sid", config.consent.gppSectionIDs.join(",")); + } + + if (config.consent.tcf) { + url.searchParams.set("tcf", config.consent.tcf); + } + if (cookies) { url.searchParams.set("cookies", "yes"); } else { @@ -25,7 +41,7 @@ function buildRequest(path: string, config: Required, init?: Requ return request; } -async function fetch(path: string, config: Required, init?: RequestInit): Promise { +async function fetch(path: string, config: ResolvedConfig, init?: RequestInit): Promise { const response = await globalThis.fetch(buildRequest(path, config, init)); const contentType = response.headers.get("Content-Type"); diff --git a/lib/core/regs/consent.test.js b/lib/core/regs/consent.test.js new file mode 100644 index 00000000..7c682b10 --- /dev/null +++ b/lib/core/regs/consent.test.js @@ -0,0 +1,342 @@ +import { getConsent } from "./consent"; +import * as gpp from "./gpp"; + +describe("getConsent", () => { + afterEach(() => { + Object.defineProperties(window, { + __tcfapi: { + value: undefined, + writable: true, + }, + __gpp: { + value: undefined, + writable: true, + }, + }); + }); + + it("allows device access when regulation is unknown", () => { + const result = getConsent(null); + expect(result).toEqual({ deviceAccess: true, reg: null }); + }); + + it("allows device access when regulation is us", () => { + const result = getConsent("us"); + expect(result).toEqual({ deviceAccess: true, reg: "us" }); + }); + + it("allows device access when regulation is can", () => { + const result = getConsent("can"); + expect(result).toEqual({ deviceAccess: true, reg: "can" }); + }); + + it("initially denies device access when regulation is gdpr", () => { + const result = getConsent("gdpr"); + expect(result).toEqual({ deviceAccess: false, reg: "gdpr" }); + }); + + it("updates consent based on tcf signals for gdpr", () => { + const signal = mockTCFSignal(); + + const consent = getConsent("gdpr"); + // By default device access is denied + expect(consent.deviceAccess).toBe(false); + expect(consent.reg).toBe("gdpr"); + + // Simulate event indicating that gdpr doesn't apply + signal({ gdprApplies: false, tcString: "doesntapply" }); + expect(consent.deviceAccess).toBe(true); + expect(consent.tcf).toBeUndefined(); + + // Simulate event where no consent is granted + signal({ + publisher: { consents: {} }, + tcString: "noconsent", + }); + expect(consent.deviceAccess).toBe(false); + expect(consent.tcf).toBe("noconsent"); + + // Simulate event where purpose 1 consent is granted to the publisher + signal({ + publisher: { consents: { 1: true } }, + tcString: "purpose1", + }); + expect(consent.deviceAccess).toBe(true); + expect(consent.tcf).toBe("purpose1"); + + // Simulate removing consent + signal({ + publisher: { consents: {} }, + tcString: "revoked", + }); + expect(consent.deviceAccess).toBe(false); + expect(consent.tcf).toBe("revoked"); + }); + + it("updates consent based on gpp signals for gdpr", () => { + const signal = mockGPPSignal(); + + let consent = getConsent("gdpr"); + // By default device access is denied + expect(consent.deviceAccess).toBe(false); + expect(consent.reg).toBe("gdpr"); + + // Simulate gpp ready event indicating no applicable sections + signal({ applicableSections: [-1], gppString: "ignored" }); + expect(consent.deviceAccess).toBe(true); + expect(consent.gpp).toBe("ignored"); + expect(consent.gppSectionIDs).toEqual([-1]); + + // Section tcfeuv2 applies but nothing in parsed sections + signal({ parsedSections: {}, applicableSections: [gpp.tcfeuv2.SectionID], gppString: "noparsedsections" }); + expect(consent.deviceAccess).toBe(false); + expect(consent.gpp).toBe("noparsedsections"); + expect(consent.gppSectionIDs).toEqual([gpp.tcfeuv2.SectionID]); + + // Section tcfeuv2 applies but no publisher segment + signal({ + parsedSections: { [gpp.tcfeuv2.APIPrefix]: [] }, + applicableSections: [gpp.tcfeuv2.SectionID], + gppString: "nopublishersegment", + }); + expect(consent.deviceAccess).toBe(false); + expect(consent.gpp).toBe("nopublishersegment"); + expect(consent.gppSectionIDs).toEqual([gpp.tcfeuv2.SectionID]); + + // Section tcfeuv2 applies but no purpose 1 consent in publisher segment + signal({ + parsedSections: { + [gpp.tcfeuv2.APIPrefix]: [ + { + SegmentType: 3, + PubPurposesConsent: [], + }, + ], + }, + applicableSections: [gpp.tcfeuv2.SectionID], + gppString: "nopurpose1consent", + }); + expect(consent.deviceAccess).toBe(false); + expect(consent.gpp).toBe("nopurpose1consent"); + expect(consent.gppSectionIDs).toEqual([gpp.tcfeuv2.SectionID]); + + // Section tcfeuv2 applies and purpose 1 granted to publisher + signal({ + parsedSections: { + [gpp.tcfeuv2.APIPrefix]: [ + { + SegmentType: 3, + PubPurposesConsent: [1], + }, + ], + }, + applicableSections: [gpp.tcfeuv2.SectionID], + gppString: "purpose1", + }); + expect(consent.deviceAccess).toBe(true); + expect(consent.gpp).toBe("purpose1"); + expect(consent.gppSectionIDs).toEqual([gpp.tcfeuv2.SectionID]); + + // Section tcfeuv2 applies and purpose 1 revoked + // to publisher + signal({ + parsedSections: { + [gpp.tcfeuv2.APIPrefix]: [ + { + SegmentType: 3, + PubPurposesConsent: [], + }, + ], + }, + applicableSections: [gpp.tcfeuv2.SectionID], + gppString: "revoked", + }); + 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] }, + // Ignored pub segment + { 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: [] }, + // Ignored pub segment + { 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("prefers tcf api over gpp when both available for gdpr", () => { + const gppSignal = mockGPPSignal(); + const tcfSignal = mockTCFSignal(); + + const consent = getConsent("gdpr"); + + // By default device access is denied + expect(consent.deviceAccess).toBe(false); + + // Grant consent via gpp api + gppSignal({ + parsedSections: { + [gpp.tcfeuv2.APIPrefix]: [ + { + SegmentType: 3, + PubPurposesConsent: [1], + }, + ], + }, + applicableSections: [gpp.tcfeuv2.SectionID], + gppString: "granted", + }); + + // Revoke consent via tcf api + tcfSignal({ + publisher: { consents: {} }, + tcString: "revoked", + }); + + // Only tcf api is considered + expect(consent.deviceAccess).toBe(false); + + // Revoke consent via gpp api + gppSignal({ + parsedSections: { + [gpp.tcfeuv2.APIPrefix]: [ + { + SegmentType: 3, + PubPurposesConsent: [], + }, + ], + }, + applicableSections: [gpp.tcfeuv2.SectionID], + gppString: "revoked", + }); + + // Grant consent via tcf api + tcfSignal({ + publisher: { consents: { 1: true } }, + tcString: "granted", + }); + + expect(consent.deviceAccess).toBe(true); + expect(consent.tcf).toBe("granted"); + expect(consent.gpp).toBe("revoked"); + }); + + it("updates consent based on gpp signals for can", () => { + const signal = mockGPPSignal(); + + const consent = getConsent("can"); + // Device access is always granted + expect(consent.deviceAccess).toBe(true); + expect(consent.reg).toBe("can"); + + // Simulate gpp ready event indicating no applicable sections + signal({ applicableSections: [-1], gppString: "ignored" }); + expect(consent.deviceAccess).toBe(true); + expect(consent.gpp).toEqual("ignored"); + expect(consent.gppSectionIDs).toEqual([-1]); + + // It listens to can section changes and propagates gpp and gpp sid + signal({ parsedSections: {}, applicableSections: [gpp.tcfcav1.SectionID], gppString: "can" }); + expect(consent.deviceAccess).toBe(true); + expect(consent.gpp).toBe("can"); + expect(consent.gppSectionIDs).toEqual([gpp.tcfcav1.SectionID]); + }); + + it("updates consent based on gpp signals for us", () => { + const signal = mockGPPSignal(); + + const consent = getConsent("us"); + // Device access is always granted + expect(consent.deviceAccess).toBe(true); + expect(consent.reg).toBe("us"); + + // Simulate gpp ready event indicating no applicable sections + signal({ applicableSections: [-1], gppString: "ignored" }); + expect(consent.deviceAccess).toBe(true); + expect(consent.gpp).toBe("ignored"); + expect(consent.gppSectionIDs).toEqual([-1]); + + // It listens to us sections changes and propagates gpp and gpp sid + signal({ parsedSections: {}, applicableSections: [gpp.usnat.SectionID], gppString: "usnat" }); + expect(consent.deviceAccess).toBe(true); + expect(consent.gpp).toBe("usnat"); + expect(consent.gppSectionIDs).toEqual([gpp.usnat.SectionID]); + + signal({ + parsedSections: {}, + applicableSections: [gpp.usnat.SectionID, gpp.usca.SectionID], + gppString: "usnat_and_usca", + }); + expect(consent.deviceAccess).toBe(true); + expect(consent.gpp).toBe("usnat_and_usca"); + expect(consent.gppSectionIDs).toEqual([gpp.usnat.SectionID, gpp.usca.SectionID]); + }); +}); + +function mockGPPSignal() { + const listeners = []; + window.__gpp = (_command, cb) => { + listeners.push(cb); + }; + + return (pingData) => { + for (const listener of listeners) { + listener( + { + data: "ready", + eventName: "signalStatus", + pingData, + }, + true + ); + } + }; +} + +function mockTCFSignal() { + const listeners = []; + + window.__tcfapi = (_command, _version, cb) => { + listeners.push(cb); + }; + + return (overrides) => { + for (const listener of listeners) { + listener( + { + gdprApplies: true, + eventStatus: "tcloaded", + ...overrides, + }, + true + ); + } + }; +} diff --git a/lib/core/regs/consent.ts b/lib/core/regs/consent.ts new file mode 100644 index 00000000..7aa30f73 --- /dev/null +++ b/lib/core/regs/consent.ts @@ -0,0 +1,135 @@ +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 + tcf?: string; + // The GPP string if available + gpp?: string; + // The GPP section IDs that are applicable + gppSectionIDs?: number[]; +}; + +function getConsent(reg: Regulation | null, conf: CMPApiConfig = {}): Consent { + const consent: Consent = { deviceAccess: true, reg }; + + onGPPChange((data) => { + consent.gpp = data.gppString; + consent.gppSectionIDs = data.applicableSections; + }); + + onTCFChange((data) => { + consent.tcf = data.gdprApplies ? data.tcString : undefined; + }); + + if (reg === "gdpr") { + consent.deviceAccess = false; + if (hasTCF()) { + onTCFChange((data) => { + consent.deviceAccess = tcfDeviceAccess(data, conf.tcfeuVendorID); + }); + } else if (hasGPP()) { + onGPPChange((data) => { + consent.deviceAccess = gppEUDeviceAccess(data, conf.tcfeuVendorID); + }); + } + } + + return consent; +} + +function gppEUDeviceAccess(data: gpp.cmpapi.PingReturn, vendorID?: number): boolean { + if (!data.applicableSections.includes(gpp.tcfeuv2.SectionID)) { + return true; + } + + 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; + }); + + if (!publisherSubsection) { + return false; + } + + return publisherSubsection.PubPurposesConsent.includes(1); +} + +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"]; +} + +function onGPPChange(cb: (_: gpp.cmpapi.PingReturn) => void): void { + if (!hasGPP()) { + return; + } + + window.__gpp?.("addEventListener", (data, success) => { + if (!success) { + return; + } + + const ready = data.eventName === "signalStatus" && data.data === "ready"; + if (!ready) { + return; + } + + cb(data.pingData); + }); +} + +function onTCFChange(cb: (_: tcf.cmpapi.TCData) => void): void { + if (!hasTCF()) { + return; + } + + window.__tcfapi?.("addEventListener", 2, (data, success) => { + if (!success) { + return; + } + const ready = data.eventStatus === "tcloaded" || data.eventStatus === "useractioncomplete"; + if (!ready) { + return; + } + cb(data); + }); +} + +function hasGPP(): boolean { + return typeof window.__gpp === "function"; +} + +function hasTCF(): boolean { + return typeof window.__tcfapi === "function"; +} + +export { getConsent, inferRegulation }; +export type { Consent }; diff --git a/lib/core/regs/gpp/cmpapi.ts b/lib/core/regs/gpp/cmpapi.ts new file mode 100644 index 00000000..6b68cf95 --- /dev/null +++ b/lib/core/regs/gpp/cmpapi.ts @@ -0,0 +1,52 @@ +import * as sections from "./sections"; + +declare global { + interface Window { + __gpp?(command: "addEventListener", cb: AddEventListenerCallback): void; + } +} + +type ParsedSections = { + [sections.tcfcav1.APIPrefix]?: sections.tcfcav1.Section; + [sections.tcfeuv2.APIPrefix]?: sections.tcfeuv2.Section; + [sections.usnat.APIPrefix]?: sections.usnat.Section; + [sections.usca.APIPrefix]?: sections.usca.Section; + [sections.usco.APIPrefix]?: sections.usco.Section; + [sections.usct.APIPrefix]?: sections.usct.Section; + [sections.usde.APIPrefix]?: sections.usde.Section; + [sections.usfl.APIPrefix]?: sections.usfl.Section; + [sections.usia.APIPrefix]?: sections.usia.Section; + [sections.usmt.APIPrefix]?: sections.usmt.Section; + [sections.usne.APIPrefix]?: sections.usne.Section; + [sections.usnh.APIPrefix]?: sections.usnh.Section; + [sections.usnj.APIPrefix]?: sections.usnj.Section; + [sections.usor.APIPrefix]?: sections.usor.Section; + [sections.ustn.APIPrefix]?: sections.ustn.Section; + [sections.ustx.APIPrefix]?: sections.ustx.Section; + [sections.usut.APIPrefix]?: sections.usut.Section; + [sections.usva.APIPrefix]?: sections.usva.Section; +}; + +type PingReturn = { + gppVersion: string; + cmpStatus: "stub" | "loading" | "loaded" | "error"; + cmpDisplayStatus: "hidden" | "visible" | "disabled"; + signalStatus: "not ready" | "ready"; + supportedAPIs: string[]; + cmpId: number; + sectionList: number[]; + applicableSections: number[]; + gppString: string; + parsedSections: ParsedSections; +}; + +type AddEventListener = { + eventName: string; + listenerId: number; + data: any; + pingData: PingReturn; +}; + +type AddEventListenerCallback = (data: AddEventListener, success: boolean) => void; + +export { PingReturn }; diff --git a/lib/core/regs/gpp/index.ts b/lib/core/regs/gpp/index.ts new file mode 100644 index 00000000..54e14732 --- /dev/null +++ b/lib/core/regs/gpp/index.ts @@ -0,0 +1,2 @@ +export * from "./sections"; +export * as cmpapi from "./cmpapi"; diff --git a/lib/core/regs/gpp/primitives.ts b/lib/core/regs/gpp/primitives.ts new file mode 100644 index 00000000..f897481c --- /dev/null +++ b/lib/core/regs/gpp/primitives.ts @@ -0,0 +1,2 @@ +type ArrayOfRanges = Array<{ key: number; type: number; ids: number[] }>; +export { ArrayOfRanges }; diff --git a/lib/core/regs/gpp/sections.ts b/lib/core/regs/gpp/sections.ts new file mode 100644 index 00000000..7f03ab9a --- /dev/null +++ b/lib/core/regs/gpp/sections.ts @@ -0,0 +1,18 @@ +export * as tcfcav1 from "./tcfcav1"; +export * as tcfeuv2 from "./tcfeuv2"; +export * as usnat from "./usnat"; +export * as usca from "./usca"; +export * as usco from "./usco"; +export * as usct from "./usct"; +export * as usde from "./usde"; +export * as usfl from "./usfl"; +export * as usia from "./usia"; +export * as usmt from "./usmt"; +export * as usne from "./usne"; +export * as usnh from "./usnh"; +export * as usnj from "./usnj"; +export * as usor from "./usor"; +export * as ustn from "./ustn"; +export * as ustx from "./ustx"; +export * as usut from "./usut"; +export * as usva from "./usva"; diff --git a/lib/core/regs/gpp/tcfcav1.ts b/lib/core/regs/gpp/tcfcav1.ts new file mode 100644 index 00000000..76c287a5 --- /dev/null +++ b/lib/core/regs/gpp/tcfcav1.ts @@ -0,0 +1,43 @@ +import { ArrayOfRanges } from "./primitives"; + +type CoreSubsection = { + Version: number; + Created: Date; + LastUpdated: Date; + CmpId: number; + CmpVersion: number; + ConsentScreen: number; + ConsentLanguage: string; + VendorListVersion: number; + TcfPolicyVersion: number; + UseNonStandardStacks: boolean; + SpecialFeatureExpressConsent: number[]; + PurposesExpressConsent: number[]; + PurposesImpliedConsent: number[]; + VendorExpressConsent: number[]; + VendorImpliedConsent: number[]; + PubRestrictions: ArrayOfRanges; +}; + +type DisclosedVendorsSubsection = { + SubsectionType: 1; + DisclosedVendors: number[]; +}; + +type PublisherPurposesSubsection = { + SubsectionType: 3; + PubPurposesExpressConsent: number[]; + PubPurposesImpliedConsent: number[]; + NumCustomPurposes: number; + CustomPurposesExpressConsent: number[]; + CustomPurposesImpliedConsent: number[]; +}; + +const SectionID = 5; +const APIPrefix = "tcfcav1"; + +type Section = Array; + +export type { Section }; + +export { APIPrefix, SectionID }; diff --git a/lib/core/regs/gpp/tcfeuv2.ts b/lib/core/regs/gpp/tcfeuv2.ts new file mode 100644 index 00000000..983cfd74 --- /dev/null +++ b/lib/core/regs/gpp/tcfeuv2.ts @@ -0,0 +1,46 @@ +import { ArrayOfRanges } from "./primitives"; + +type CoreSegment = { + Version: number; + Created: Date; + LastUpdated: Date; + CmpId: number; + CmpVersion: number; + ConsentScreen: number; + ConsentLanguage: string; + VendorListVersion: number; + TcfPolicyVersion: number; + IsServiceSpecific: boolean; + UseNonStandardTexts: boolean; + SpecialFeatureOptIns: number[]; + PurposeConsent: number[]; + PurposesLITransparency: number[]; + PurposeOneTreatment: boolean; + PublisherCC: string; + VendorConsent: number[]; + VendorLegitimateInterest: number[]; + PubRestrictions: ArrayOfRanges; +}; + +type DisclosedVendorsSegment = { + SegmentType: 1; + DisclosedVendors: number[]; +}; + +type PublisherPurposesSegment = { + SegmentType: 3; + PubPurposesConsent: number[]; + PubPurposesLITransparency: number[]; + NumCustomPurposes: number; + CustomPurposesConsent: number[]; + CustomPurposesLITransparency: number[]; +}; + +const SectionID = 2; +const APIPrefix = "tcfeuv2"; + +type Section = Array; + +export type { Section }; + +export { APIPrefix, SectionID }; diff --git a/lib/core/regs/gpp/usca.ts b/lib/core/regs/gpp/usca.ts new file mode 100644 index 00000000..46fa1279 --- /dev/null +++ b/lib/core/regs/gpp/usca.ts @@ -0,0 +1,23 @@ +type CoreSubsection = { + Version: number; + SaleOptOutNotice: number; + SharingOptOutNotice: number; + SensitiveDataLimitUseNotice: number; + SaleOptOut: number; + SharingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number[]; + PersonalDataConsents: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 8; +const APIPrefix = "usca"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/usco.ts b/lib/core/regs/gpp/usco.ts new file mode 100644 index 00000000..b246994a --- /dev/null +++ b/lib/core/regs/gpp/usco.ts @@ -0,0 +1,22 @@ +type CoreSubsection = { + Version: number; + SharingNotice: number; + SaleOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SaleOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 10; +const APIPrefix = "usco"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/usct.ts b/lib/core/regs/gpp/usct.ts new file mode 100644 index 00000000..4c9ba8fd --- /dev/null +++ b/lib/core/regs/gpp/usct.ts @@ -0,0 +1,22 @@ +type CoreSubsection = { + Version: number; + SharingNotice: number; + SaleOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SaleOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 12; +const APIPrefix = "usct"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/usde.ts b/lib/core/regs/gpp/usde.ts new file mode 100644 index 00000000..045687f0 --- /dev/null +++ b/lib/core/regs/gpp/usde.ts @@ -0,0 +1,23 @@ +type CoreSubsection = { + Version: number; + ProcessingNotice: number; + SaleOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SaleOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number; + AdditionalDataProcessingConsent: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 17; +const APIPrefix = "usde"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/usfl.ts b/lib/core/regs/gpp/usfl.ts new file mode 100644 index 00000000..4f289bd8 --- /dev/null +++ b/lib/core/regs/gpp/usfl.ts @@ -0,0 +1,23 @@ +type CoreSubsection = { + Version: number; + ProcessingNotice: number; + SaleOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SaleOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number; + AdditionalDataProcessingConsent: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 13; +const APIPrefix = "usfl"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/usia.ts b/lib/core/regs/gpp/usia.ts new file mode 100644 index 00000000..01b3b975 --- /dev/null +++ b/lib/core/regs/gpp/usia.ts @@ -0,0 +1,23 @@ +type CoreSubsection = { + Version: number; + ProcessingNotice: number; + SaleOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SensitiveDataOptOutNotice: number; + SaleOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 18; +const APIPrefix = "usia"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/usmt.ts b/lib/core/regs/gpp/usmt.ts new file mode 100644 index 00000000..4e852305 --- /dev/null +++ b/lib/core/regs/gpp/usmt.ts @@ -0,0 +1,23 @@ +type CoreSubsection = { + Version: number; + SharingNotice: number; + SaleOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SaleOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number; + AdditionalDataProcessingConsent: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 14; +const APIPrefix = "usmt"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/usnat.ts b/lib/core/regs/gpp/usnat.ts new file mode 100644 index 00000000..989605eb --- /dev/null +++ b/lib/core/regs/gpp/usnat.ts @@ -0,0 +1,27 @@ +type CoreSegment = { + Version: number; + SharingNotice: number; + SaleOptOutNotice: number; + SharingOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SensitiveDataProcessingOptOutNotice: number; + SensitiveDataLimitUseNotice: number; + SaleOptOut: number; + SharingOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number[]; + PersonalDataConsents: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +const SectionID = 7; +const APIPrefix = "usnat"; + +type Section = Array; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/usne.ts b/lib/core/regs/gpp/usne.ts new file mode 100644 index 00000000..fcaa6b2b --- /dev/null +++ b/lib/core/regs/gpp/usne.ts @@ -0,0 +1,23 @@ +type CoreSubsection = { + Version: number; + ProcessingNotice: number; + SaleOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SaleOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number; + AdditionalDataProcessingConsent: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 19; +const APIPrefix = "usne"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/usnh.ts b/lib/core/regs/gpp/usnh.ts new file mode 100644 index 00000000..49c13750 --- /dev/null +++ b/lib/core/regs/gpp/usnh.ts @@ -0,0 +1,23 @@ +type CoreSubsection = { + Version: number; + ProcessingNotice: number; + SaleOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SaleOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number; + AdditionalDataProcessingConsent: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 20; +const APIPrefix = "usnh"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/usnj.ts b/lib/core/regs/gpp/usnj.ts new file mode 100644 index 00000000..389f0f1b --- /dev/null +++ b/lib/core/regs/gpp/usnj.ts @@ -0,0 +1,23 @@ +type CoreSubsection = { + Version: number; + ProcessingNotice: number; + SaleOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SaleOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number; + AdditionalDataProcessingConsent: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 21; +const APIPrefix = "usnj"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/usor.ts b/lib/core/regs/gpp/usor.ts new file mode 100644 index 00000000..66c88d4d --- /dev/null +++ b/lib/core/regs/gpp/usor.ts @@ -0,0 +1,23 @@ +type CoreSubsection = { + Version: number; + ProcessingNotice: number; + SaleOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SaleOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number; + AdditionalDataProcessingConsent: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 15; +const APIPrefix = "usor"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/ustn.ts b/lib/core/regs/gpp/ustn.ts new file mode 100644 index 00000000..a0e9d38b --- /dev/null +++ b/lib/core/regs/gpp/ustn.ts @@ -0,0 +1,23 @@ +type CoreSubsection = { + Version: number; + ProcessingNotice: number; + SaleOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SaleOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number; + AdditionalDataProcessingConsent: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 22; +const APIPrefix = "ustn"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/ustx.ts b/lib/core/regs/gpp/ustx.ts new file mode 100644 index 00000000..9c743ca7 --- /dev/null +++ b/lib/core/regs/gpp/ustx.ts @@ -0,0 +1,23 @@ +type CoreSubsection = { + Version: number; + ProcessingNotice: number; + SaleOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SaleOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number; + AdditionalDataProcessingConsent: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 16; +const APIPrefix = "ustx"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/usut.ts b/lib/core/regs/gpp/usut.ts new file mode 100644 index 00000000..7d66a575 --- /dev/null +++ b/lib/core/regs/gpp/usut.ts @@ -0,0 +1,22 @@ +type CoreSubsection = { + Version: number; + SharingNotice: number; + SaleOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SaleOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 11; +const APIPrefix = "usut"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/gpp/usva.ts b/lib/core/regs/gpp/usva.ts new file mode 100644 index 00000000..dedb0296 --- /dev/null +++ b/lib/core/regs/gpp/usva.ts @@ -0,0 +1,22 @@ +type CoreSubsection = { + Version: number; + SharingNotice: number; + SaleOptOutNotice: number; + TargetedAdvertisingOptOutNotice: number; + SaleOptOut: number; + TargetedAdvertisingOptOut: number; + SensitiveDataProcessing: number[]; + KnownChildSensitiveDataConsents: number; + MspaCoveredTransaction: number; + MspaOptOutOptionMode: number; + MspaServiceProviderMode: number; +}; + +type Section = Array; + +const SectionID = 9; +const APIPrefix = "usva"; + +export type { Section }; + +export { SectionID, APIPrefix }; diff --git a/lib/core/regs/regulations.test.js b/lib/core/regs/regulations.test.js new file mode 100644 index 00000000..4fd97538 --- /dev/null +++ b/lib/core/regs/regulations.test.js @@ -0,0 +1,47 @@ +import { inferRegulation } from "./regulations"; + +describe("inferRegulation", () => { + let timezoneMock = ""; + let languagesMock = []; + + beforeEach(() => { + jest.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({ + resolvedOptions: () => ({ + timeZone: timezoneMock, + }), + })); + + jest.spyOn(navigator, "languages", "get").mockImplementation(() => languagesMock); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should return the us regulation when given us time zone", () => { + timezoneMock = "America/New_York"; + expect(inferRegulation()).toBe("us"); + }); + + it("should return the gdpr regulation when given eu time zone", () => { + timezoneMock = "Europe/Paris"; + expect(inferRegulation()).toBe("gdpr"); + }); + + it("should return the can regulation when infered to be in quebec", () => { + timezoneMock = "America/Toronto"; + expect(inferRegulation()).toBeNull(); + + languagesMock = ["en", "fr-FR"]; + expect(inferRegulation()).toBeNull(); + + languagesMock = ["en", "fr-CA"]; + expect(inferRegulation()).toBe("can"); + + languagesMock = ["en", "fr"]; + expect(inferRegulation()).toBe("can"); + + timezoneMock = "America/Moncton"; + expect(inferRegulation()).toBeNull(); + }); +}); diff --git a/lib/core/regs/regulations.ts b/lib/core/regs/regulations.ts new file mode 100644 index 00000000..bc2cb414 --- /dev/null +++ b/lib/core/regs/regulations.ts @@ -0,0 +1,76 @@ +type Regulation = "gdpr" | "us" | "can"; + +const zones: { [k: string]: Regulation } = { + // EU + "Europe/Amsterdam": "gdpr", + "Europe/Athens": "gdpr", + "Europe/Berlin": "gdpr", + "Europe/Brussels": "gdpr", + "Europe/Bucharest": "gdpr", + "Europe/Budapest": "gdpr", + "Europe/Copenhagen": "gdpr", + "Europe/Dublin": "gdpr", + "Europe/Helsinki": "gdpr", + "Europe/Lisbon": "gdpr", + "Europe/London": "gdpr", + "Europe/Madrid": "gdpr", + "Europe/Oslo": "gdpr", + "Europe/Paris": "gdpr", + "Europe/Prague": "gdpr", + "Europe/Rome": "gdpr", + "Europe/Sofia": "gdpr", + "Europe/Stockholm": "gdpr", + "Europe/Vienna": "gdpr", + "Europe/Warsaw": "gdpr", + "Europe/Zurich": "gdpr", + + // CA (QC only) + "America/Toronto": "can", + + // US + "America/New_York": "us", + "America/Detroit": "us", + "America/Kentucky/Louisville": "us", + "America/Kentucky/Monticello": "us", + "America/Indiana/Indianapolis": "us", + "America/Indiana/Vincennes": "us", + "America/Indiana/Winamac": "us", + "America/Indiana/Marengo": "us", + "America/Indiana/Petersburg": "us", + "America/Indiana/Vevay": "us", + "America/Chicago": "us", + "America/Indiana/Tell_City": "us", + "America/Indiana/Knox": "us", + "America/Menominee": "us", + "America/North_Dakota/Center": "us", + "America/North_Dakota/New_Salem": "us", + "America/North_Dakota/Beulah": "us", + "America/Denver": "us", + "America/Boise": "us", + "America/Phoenix": "us", + "America/Los_Angeles": "us", + "America/Anchorage": "us", + "America/Juneau": "us", + "America/Sitka": "us", + "America/Metlakatla": "us", + "America/Yakutat": "us", + "America/Nome": "us", + "America/Adak": "us", + "Pacific/Honolulu": "us", +}; + +function inferRegulation(): Regulation | null { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const regulation = zones[timeZone]; + + // Special handling, can currently only applies to quebec + if (regulation === "can") { + const inQC = ["fr", "fr-CA"].some((l) => navigator.languages.includes(l)); + return inQC ? "can" : null; + } + + return regulation ?? null; +} + +export { inferRegulation }; +export type { Regulation }; diff --git a/lib/core/regs/storage.test.js b/lib/core/regs/storage.test.js new file mode 100644 index 00000000..d30d8dac --- /dev/null +++ b/lib/core/regs/storage.test.js @@ -0,0 +1,52 @@ +import { LocalStorageProxy } from "./storage"; + +describe("LocalStorageProxy", () => { + let windowSpy; + + let localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }; + + beforeEach(() => { + windowSpy = jest.spyOn(window, "window", "get"); + windowSpy.mockImplementation(() => ({ + localStorage: localStorageMock, + })); + }); + + afterEach(() => { + localStorageMock.getItem.mockClear(); + localStorageMock.setItem.mockClear(); + localStorageMock.removeItem.mockClear(); + windowSpy.mockRestore(); + }); + + it("proxies to underlying storage when consent granted", () => { + const storage = new LocalStorageProxy({ deviceAccess: true }); + + storage.getItem("key"); + expect(localStorageMock.getItem).toHaveBeenCalledWith("key"); + + storage.setItem("key", "value"); + expect(localStorageMock.setItem).toHaveBeenCalledWith("key", "value"); + + storage.removeItem("key"); + expect(localStorageMock.removeItem).toHaveBeenCalledWith("key"); + }); + + it("doesn't access underlying storage when consent not granted", () => { + const storage = new LocalStorageProxy({ deviceAccess: false }); + + const result = storage.getItem("key"); + expect(localStorageMock.getItem).not.toHaveBeenCalled(); + expect(result).toBeNull(); + + storage.setItem("key", "value"); + expect(localStorageMock.setItem).not.toHaveBeenCalled(); + + storage.removeItem("key"); + expect(localStorageMock.removeItem).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/core/regs/storage.ts b/lib/core/regs/storage.ts new file mode 100644 index 00000000..bc44f753 --- /dev/null +++ b/lib/core/regs/storage.ts @@ -0,0 +1,34 @@ +import { Consent } from "./consent"; + +class LocalStorageProxy { + private consent: Consent; + constructor(consent: Consent) { + this.consent = consent; + } + + getItem(key: string): string | null { + if (!this.consent.deviceAccess) { + return null; + } + + return window.localStorage.getItem(key); + } + + setItem(key: string, value: string): void { + if (!this.consent.deviceAccess) { + return; + } + + window.localStorage.setItem(key, value); + } + + removeItem(key: string): void { + if (!this.consent.deviceAccess) { + return; + } + + window.localStorage.removeItem(key); + } +} + +export { LocalStorageProxy }; diff --git a/lib/core/regs/tcf/cmpapi.ts b/lib/core/regs/tcf/cmpapi.ts new file mode 100644 index 00000000..34321594 --- /dev/null +++ b/lib/core/regs/tcf/cmpapi.ts @@ -0,0 +1,64 @@ +declare global { + interface Window { + __tcfapi?(command: "addEventListener", version: number, cb: AddEventListenerCallback): void; + } +} + +type TCData = { + tcString: string; + tcfPolicyVersion: number; + cmpId: number; + cmpVersion: number; + gdprApplies?: boolean; + eventStatus: "tcloaded" | "cmpuishown" | "useractioncomplete"; + cmpStatus: "stub" | "loading" | "loaded" | "error"; + listenerId?: number; + isServiceSpecific: boolean; + useNonStandardStacks: boolean; + publisherCC: string; + purposeOneTreatment: boolean; + purpose: { + consents: { + [purposeID: string]: boolean; + }; + legitimateInterests: { + [purposeID: string]: boolean; + }; + }; + vendor: { + consents: { + [vendorID: string]: boolean; + }; + legitimateInterests: { + [vendorID: string]: boolean; + }; + }; + specialFeatureOptins: { + [featureID: string]: boolean; + }; + publisher: { + consents: { + [purposeID: string]: boolean; + }; + legitimateInterests: { + [purposeID: string]: boolean; + }; + customPurpose: { + consents: { + [purposeID: string]: boolean; + }; + legitimateInterests: { + [purposeID: string]: boolean; + }; + }; + restrictions: { + [purposeID: string]: { + [vendorID: string]: 0 | 1 | 2; + }; + }; + }; +}; + +type AddEventListenerCallback = (data: TCData, success: boolean) => void; + +export { TCData }; diff --git a/lib/core/regs/tcf/index.ts b/lib/core/regs/tcf/index.ts new file mode 100644 index 00000000..f7e9eb49 --- /dev/null +++ b/lib/core/regs/tcf/index.ts @@ -0,0 +1 @@ +export * as cmpapi from "./cmpapi"; diff --git a/lib/core/storage.test.js b/lib/core/storage.test.js index 483f15af..c6d5702a 100644 --- a/lib/core/storage.test.js +++ b/lib/core/storage.test.js @@ -3,7 +3,11 @@ import crypto from "crypto"; function randomConfig() { const randomHex = crypto.randomBytes(8).toString("hex"); - return { host: `host-${randomHex}`, site: `site-${randomHex}` }; + return { + host: `host-${randomHex}`, + site: `site-${randomHex}`, + consent: { deviceAccess: true }, + }; } describe("LocalStorage", () => { diff --git a/lib/core/storage.ts b/lib/core/storage.ts index 1bc6eab5..d7356ed5 100644 --- a/lib/core/storage.ts +++ b/lib/core/storage.ts @@ -1,6 +1,7 @@ import { SiteResponse } from "../edge/site"; -import type { OptableConfig } from "../config"; +import type { ResolvedConfig } from "../config"; import type { TargetingResponse } from "../edge/targeting"; +import { LocalStorageProxy } from "./regs/storage"; function toBinary(str: string): string { const codeUnits = new Uint16Array(str.length); @@ -16,22 +17,25 @@ class LocalStorage { private targetingKey: string; private siteKey: string; - constructor(private Config: OptableConfig) { - const sfx = btoa(toBinary(`${this.Config.host}/${this.Config.site}`)); + private storage: LocalStorageProxy; + + constructor(private config: ResolvedConfig) { + const sfx = btoa(toBinary(`${this.config.host}/${this.config.site}`)); // Legacy targeting key this.targetingV1Key = "OPTABLE_TGT_" + sfx; this.passportKey = "OPTABLE_PASS_" + sfx; this.targetingKey = "OPTABLE_V2_TGT_" + sfx; this.siteKey = "OPTABLE_SITE_" + sfx; + this.storage = new LocalStorageProxy(this.config.consent); } getPassport(): string | null { - return window.localStorage.getItem(this.passportKey); + return this.storage.getItem(this.passportKey); } getV1Targeting(): TargetingResponse | null { - const raw = window.localStorage.getItem(this.targetingV1Key); + const raw = this.storage.getItem(this.targetingV1Key); const parsed = raw ? JSON.parse(raw) : null; if (!parsed) { return null; @@ -57,14 +61,14 @@ class LocalStorage { } getTargeting(): TargetingResponse | null { - const raw = window.localStorage.getItem(this.targetingKey); + const raw = this.storage.getItem(this.targetingKey); const parsed = raw ? JSON.parse(raw) : null; return parsed ? parsed : this.getV1Targeting(); } setPassport(passport: string) { if (passport && passport.length > 0) { - window.localStorage.setItem(this.passportKey, passport); + this.storage.setItem(this.passportKey, passport); } } @@ -73,32 +77,32 @@ class LocalStorage { return; } - window.localStorage.setItem(this.targetingKey, JSON.stringify(targeting)); + this.storage.setItem(this.targetingKey, JSON.stringify(targeting)); } setSite(site?: SiteResponse | null) { if (!site) { return; } - window.localStorage.setItem(this.siteKey, JSON.stringify(site)); + this.storage.setItem(this.siteKey, JSON.stringify(site)); } getSite(): SiteResponse | null { - const raw = window.localStorage.getItem(this.siteKey); + const raw = this.storage.getItem(this.siteKey); const parsed = raw ? JSON.parse(raw) : null; return parsed; } clearPassport() { - window.localStorage.removeItem(this.passportKey); + this.storage.removeItem(this.passportKey); } clearTargeting() { - window.localStorage.removeItem(this.targetingKey); + this.storage.removeItem(this.targetingKey); } clearSite() { - window.localStorage.removeItem(this.siteKey); + this.storage.removeItem(this.siteKey); } } diff --git a/lib/edge/identify.ts b/lib/edge/identify.ts index dcd44231..b8f1dc39 100644 --- a/lib/edge/identify.ts +++ b/lib/edge/identify.ts @@ -1,7 +1,7 @@ -import type { OptableConfig } from "../config"; +import type { ResolvedConfig } from "../config"; import { fetch } from "../core/network"; -function Identify(config: Required, ids: string[]): Promise { +function Identify(config: ResolvedConfig, ids: string[]): Promise { return fetch("/identify", config, { method: "POST", headers: { diff --git a/lib/edge/profile.ts b/lib/edge/profile.ts index d49b6c16..68401ca5 100644 --- a/lib/edge/profile.ts +++ b/lib/edge/profile.ts @@ -1,11 +1,11 @@ -import type { OptableConfig } from "../config"; +import type { ResolvedConfig } from "../config"; import { fetch } from "../core/network"; type ProfileTraits = { [key: string]: string | number | boolean; }; -function Profile(config: Required, traits: ProfileTraits): Promise { +function Profile(config: ResolvedConfig, traits: ProfileTraits): Promise { const profile = { traits: traits, }; diff --git a/lib/edge/site.ts b/lib/edge/site.ts index b0b88fea..e67c3959 100644 --- a/lib/edge/site.ts +++ b/lib/edge/site.ts @@ -1,4 +1,4 @@ -import type { OptableConfig } from "../config"; +import type { ResolvedConfig } from "../config"; import { fetch } from "../core/network"; import { LocalStorage } from "../core/storage"; @@ -23,7 +23,7 @@ type SiteResponse = { }; // Grab the site configuration from the server and store it in local storage -async function Site(config: Required): Promise { +async function Site(config: ResolvedConfig): Promise { const response: SiteResponse = await fetch("/config", config, { method: "GET", headers: { Accept: "application/json" }, @@ -35,7 +35,7 @@ async function Site(config: Required): Promise { } // Obtain the site configuration from local storage -function SiteFromCache(config: Required): SiteResponse | null { +function SiteFromCache(config: ResolvedConfig): SiteResponse | null { const ls = new LocalStorage(config); return ls.getSite(); } diff --git a/lib/edge/targeting.ts b/lib/edge/targeting.ts index b4475719..19498976 100644 --- a/lib/edge/targeting.ts +++ b/lib/edge/targeting.ts @@ -1,4 +1,4 @@ -import type { OptableConfig } from "../config"; +import type { ResolvedConfig } from "../config"; import { fetch } from "../core/network"; import { LocalStorage } from "../core/storage"; import { UIDAgentType, User as RTB2User } from "./rtb2"; @@ -24,7 +24,7 @@ type TargetingResponse = { user?: UserIdentifiers[]; }; -async function Targeting(config: Required, id: string): Promise { +async function Targeting(config: ResolvedConfig, id: string): Promise { const searchParams = new URLSearchParams({ id }); const path = "/v2/targeting?" + searchParams.toString(); @@ -41,12 +41,12 @@ async function Targeting(config: Required, id: string): Promise): TargetingResponse | null { +function TargetingFromCache(config: ResolvedConfig): TargetingResponse | null { const ls = new LocalStorage(config); return ls.getTargeting(); } -function TargetingClearCache(config: Required) { +function TargetingClearCache(config: ResolvedConfig) { const ls = new LocalStorage(config); ls.clearTargeting(); } diff --git a/lib/edge/tokenize.ts b/lib/edge/tokenize.ts index 53c20d55..608fa6f9 100644 --- a/lib/edge/tokenize.ts +++ b/lib/edge/tokenize.ts @@ -1,4 +1,4 @@ -import type { OptableConfig } from "../config"; +import type { ResolvedConfig } from "../config"; import { fetch } from "../core/network"; import { User } from "./rtb2"; @@ -10,7 +10,7 @@ type TokenizeRequest = { id: string; }; -function Tokenize(config: Required, id: string): Promise { +function Tokenize(config: ResolvedConfig, id: string): Promise { let request: TokenizeRequest = { id: id, }; diff --git a/lib/edge/uid2_token.ts b/lib/edge/uid2_token.ts index a3bb73f0..788c4841 100644 --- a/lib/edge/uid2_token.ts +++ b/lib/edge/uid2_token.ts @@ -1,4 +1,4 @@ -import type { OptableConfig } from "../config"; +import type { ResolvedConfig } from "../config"; import { fetch } from "../core/network"; type Uid2TokenResponse = { @@ -10,7 +10,7 @@ type Uid2TokenResponse = { RefreshResponseKey: string; }; -function Uid2Token(config: Required, id: string): Promise { +function Uid2Token(config: ResolvedConfig, id: string): Promise { return fetch("/uid2/token", config, { method: "POST", headers: { diff --git a/lib/edge/witness.ts b/lib/edge/witness.ts index 4a6d5a7e..ae9d0248 100644 --- a/lib/edge/witness.ts +++ b/lib/edge/witness.ts @@ -1,11 +1,11 @@ -import type { OptableConfig } from "../config"; +import type { ResolvedConfig } from "../config"; import { fetch } from "../core/network"; type WitnessProperties = { [key: string]: string | number | boolean; }; -function Witness(config: Required, event: string, properties: WitnessProperties): Promise { +function Witness(config: ResolvedConfig, event: string, properties: WitnessProperties): Promise { const evt = { event: event, properties: properties, diff --git a/lib/sdk.ts b/lib/sdk.ts index 21057294..9da89b3a 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -1,4 +1,4 @@ -import type { OptableConfig } from "./config"; +import type { InitConfig, ResolvedConfig } from "./config"; import { default as buildInfo } from "./build.json"; import { getConfig } from "./config"; import type { WitnessProperties } from "./edge/witness"; @@ -22,10 +22,10 @@ import { Tokenize, TokenizeResponse } from "./edge/tokenize"; class OptableSDK { public static version = buildInfo.version; - public dcn: Required; + public dcn: ResolvedConfig; private init: Promise; - constructor(dcn: OptableConfig) { + constructor(dcn: InitConfig) { this.dcn = getConfig(dcn); // If initPassport, prefetch site config and cache it, it assigns a passport as a side effect const noop = () => {}; @@ -135,5 +135,5 @@ class OptableSDK { } export { OptableSDK }; -export type { OptableConfig }; +export type { InitConfig }; export default OptableSDK; diff --git a/package-lock.json b/package-lock.json index ff9a69c3..0655bb90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "shellcheck": "^3.0.0", "typescript": "^5.2.2", "webpack": "^5.97.1", - "webpack-cli": "^4.7.2", + "webpack-cli": "^5.1.4", "whatwg-fetch": "^3.6.20" } }, @@ -2693,10 +2693,14 @@ "dev": true }, "node_modules/@types/node": { - "version": "14.14.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.7.tgz", - "integrity": "sha512-Zw1vhUSQZYw+7u5dAwNbIA9TuTotpzY/OF7sJM9FqPOF3SPjKnxrjoTktXDZgUjybf4cWVBP7O8wvKdSaGHweg==", - "dev": true + "version": "22.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz", + "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } }, "node_modules/@types/stack-utils": { "version": "2.0.3", @@ -2887,34 +2891,45 @@ } }, "node_modules/@webpack-cli/configtest": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.0.4.tgz", - "integrity": "sha512-cs3XLy+UcxiP6bj0A6u7MLLuwdXJ1c3Dtc0RkKg+wiI1g/Ti1om8+/2hc2A2B60NbBNAbMgyBMHvyymWm/j4wQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, "peerDependencies": { - "webpack": "4.x.x || 5.x.x", - "webpack-cli": "4.x.x" + "webpack": "5.x.x", + "webpack-cli": "5.x.x" } }, "node_modules/@webpack-cli/info": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.3.0.tgz", - "integrity": "sha512-ASiVB3t9LOKHs5DyVUcxpraBXDOKubYu/ihHhU+t1UPpxsivg6Od2E2qU4gJCekfEddzRBzHhzA/Acyw/mlK/w==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", "dev": true, - "dependencies": { - "envinfo": "^7.7.3" + "license": "MIT", + "engines": { + "node": ">=14.15.0" }, "peerDependencies": { - "webpack-cli": "4.x.x" + "webpack": "5.x.x", + "webpack-cli": "5.x.x" } }, "node_modules/@webpack-cli/serve": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.5.1.tgz", - "integrity": "sha512-4vSVUiOPJLmr45S8rMGy7WDvpWxfFxfP/Qx/cxZFCfvoypTYpPPL1X8VIZMe0WTA+Jr7blUxwUSEZNkjoMTgSw==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, "peerDependencies": { - "webpack-cli": "4.x.x" + "webpack": "5.x.x", + "webpack-cli": "5.x.x" }, "peerDependenciesMeta": { "webpack-dev-server": { @@ -3597,10 +3612,11 @@ "dev": true }, "node_modules/colorette": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", - "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==", - "dev": true + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -4204,10 +4220,11 @@ "dev": true }, "node_modules/envinfo": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.8.1.tgz", - "integrity": "sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.14.0.tgz", + "integrity": "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==", "dev": true, + "license": "MIT", "bin": { "envinfo": "dist/cli.js" }, @@ -4916,12 +4933,13 @@ "dev": true }, "node_modules/interpret": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", - "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">=10.13.0" } }, "node_modules/is-arrayish": { @@ -8676,15 +8694,16 @@ "dev": true }, "node_modules/rechoir": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", - "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", "dev": true, + "license": "MIT", "dependencies": { - "resolve": "^1.9.0" + "resolve": "^1.20.0" }, "engines": { - "node": ">= 0.10" + "node": ">= 10.13.0" } }, "node_modules/regenerate": { @@ -9448,10 +9467,11 @@ } }, "node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9470,6 +9490,13 @@ "through": "^2.3.8" } }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", @@ -9575,12 +9602,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, - "node_modules/v8-compile-cache": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true - }, "node_modules/v8-to-istanbul": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", @@ -9686,41 +9707,43 @@ } }, "node_modules/webpack-cli": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.7.2.tgz", - "integrity": "sha512-mEoLmnmOIZQNiRl0ebnjzQ74Hk0iKS5SiEEnpq3dRezoyR3yPaeQZCMCe+db4524pj1Pd5ghZXjT41KLzIhSLw==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, + "license": "MIT", "dependencies": { "@discoveryjs/json-ext": "^0.5.0", - "@webpack-cli/configtest": "^1.0.4", - "@webpack-cli/info": "^1.3.0", - "@webpack-cli/serve": "^1.5.1", - "colorette": "^1.2.1", - "commander": "^7.0.0", - "execa": "^5.0.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", + "colorette": "^2.0.14", + "commander": "^10.0.1", + "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", - "interpret": "^2.2.0", - "rechoir": "^0.7.0", - "v8-compile-cache": "^2.2.0", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", "webpack-merge": "^5.7.3" }, "bin": { "webpack-cli": "bin/cli.js" }, "engines": { - "node": ">=10.13.0" + "node": ">=14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "4.x.x || 5.x.x" + "webpack": "5.x.x" }, "peerDependenciesMeta": { "@webpack-cli/generators": { "optional": true }, - "@webpack-cli/migrate": { - "optional": true - }, "webpack-bundle-analyzer": { "optional": true }, @@ -9730,12 +9753,13 @@ } }, "node_modules/webpack-cli/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "dev": true, + "license": "MIT", "engines": { - "node": ">= 10" + "node": ">=14" } }, "node_modules/webpack-merge": { @@ -12282,10 +12306,13 @@ "dev": true }, "@types/node": { - "version": "14.14.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.7.tgz", - "integrity": "sha512-Zw1vhUSQZYw+7u5dAwNbIA9TuTotpzY/OF7sJM9FqPOF3SPjKnxrjoTktXDZgUjybf4cWVBP7O8wvKdSaGHweg==", - "dev": true + "version": "22.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz", + "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", + "dev": true, + "requires": { + "undici-types": "~6.20.0" + } }, "@types/stack-utils": { "version": "2.0.3", @@ -12461,25 +12488,23 @@ } }, "@webpack-cli/configtest": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.0.4.tgz", - "integrity": "sha512-cs3XLy+UcxiP6bj0A6u7MLLuwdXJ1c3Dtc0RkKg+wiI1g/Ti1om8+/2hc2A2B60NbBNAbMgyBMHvyymWm/j4wQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", "dev": true, "requires": {} }, "@webpack-cli/info": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.3.0.tgz", - "integrity": "sha512-ASiVB3t9LOKHs5DyVUcxpraBXDOKubYu/ihHhU+t1UPpxsivg6Od2E2qU4gJCekfEddzRBzHhzA/Acyw/mlK/w==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", "dev": true, - "requires": { - "envinfo": "^7.7.3" - } + "requires": {} }, "@webpack-cli/serve": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.5.1.tgz", - "integrity": "sha512-4vSVUiOPJLmr45S8rMGy7WDvpWxfFxfP/Qx/cxZFCfvoypTYpPPL1X8VIZMe0WTA+Jr7blUxwUSEZNkjoMTgSw==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", "dev": true, "requires": {} }, @@ -12978,9 +13003,9 @@ "dev": true }, "colorette": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", - "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==", + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true }, "combined-stream": { @@ -13451,9 +13476,9 @@ } }, "envinfo": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.8.1.tgz", - "integrity": "sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.14.0.tgz", + "integrity": "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==", "dev": true }, "error-ex": { @@ -13963,9 +13988,9 @@ "dev": true }, "interpret": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", - "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", "dev": true }, "is-arrayish": { @@ -16832,12 +16857,12 @@ } }, "rechoir": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", - "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", "dev": true, "requires": { - "resolve": "^1.9.0" + "resolve": "^1.20.0" } }, "regenerate": { @@ -17407,9 +17432,9 @@ "dev": true }, "typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true }, "unbzip2-stream": { @@ -17422,6 +17447,12 @@ "through": "^2.3.8" } }, + "undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true + }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", @@ -17491,12 +17522,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, - "v8-compile-cache": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true - }, "v8-to-istanbul": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", @@ -17587,30 +17612,30 @@ } }, "webpack-cli": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.7.2.tgz", - "integrity": "sha512-mEoLmnmOIZQNiRl0ebnjzQ74Hk0iKS5SiEEnpq3dRezoyR3yPaeQZCMCe+db4524pj1Pd5ghZXjT41KLzIhSLw==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", - "@webpack-cli/configtest": "^1.0.4", - "@webpack-cli/info": "^1.3.0", - "@webpack-cli/serve": "^1.5.1", - "colorette": "^1.2.1", - "commander": "^7.0.0", - "execa": "^5.0.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", + "colorette": "^2.0.14", + "commander": "^10.0.1", + "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", - "interpret": "^2.2.0", - "rechoir": "^0.7.0", - "v8-compile-cache": "^2.2.0", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", "webpack-merge": "^5.7.3" }, "dependencies": { "commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "dev": true } } diff --git a/package.json b/package.json index 33863e45..35b50282 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "shellcheck": "^3.0.0", "typescript": "^5.2.2", "webpack": "^5.97.1", - "webpack-cli": "^4.7.2", + "webpack-cli": "^5.1.4", "whatwg-fetch": "^3.6.20" }, "dependencies": {