Skip to content

Commit

Permalink
Add GPP/TCF cmpapi integration to respect device access in EU/CA/US (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
zapo authored Jan 7, 2025
1 parent 1d70c16 commit cfc8e08
Show file tree
Hide file tree
Showing 47 changed files with 1,600 additions and 168 deletions.
4 changes: 2 additions & 2 deletions demos/react/src/identify.tsx
Original file line number Diff line number Diff line change
@@ -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<OptableSDK | null>(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
Expand Down
49 changes: 49 additions & 0 deletions lib/config.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
45 changes: 40 additions & 5 deletions lib/config.ts
Original file line number Diff line number Diff line change
@@ -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<Omit<InitConfig, "consent">> & {
consent: Consent;
};

const DCN_DEFAULTS = {
cookies: true,
initPassport: true,
consent: { deviceAccess: true, reg: null },
};

function getConfig(config: OptableConfig): Required<OptableConfig> {
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 };
23 changes: 20 additions & 3 deletions lib/core/network.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
]);
});
});
22 changes: 19 additions & 3 deletions lib/core/network.ts
Original file line number Diff line number Diff line change
@@ -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<OptableConfig>, 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 {
Expand All @@ -25,7 +41,7 @@ function buildRequest(path: string, config: Required<OptableConfig>, init?: Requ
return request;
}

async function fetch<T>(path: string, config: Required<OptableConfig>, init?: RequestInit): Promise<T> {
async function fetch<T>(path: string, config: ResolvedConfig, init?: RequestInit): Promise<T> {
const response = await globalThis.fetch(buildRequest(path, config, init));

const contentType = response.headers.get("Content-Type");
Expand Down
Loading

0 comments on commit cfc8e08

Please sign in to comment.