Skip to content

Commit cfc8e08

Browse files
authored
Add GPP/TCF cmpapi integration to respect device access in EU/CA/US (#152)
1 parent 1d70c16 commit cfc8e08

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1600
-168
lines changed

demos/react/src/identify.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import React, { useContext, createContext, useState } from "react";
22
import ReactDOM from "react-dom";
3-
import OptableSDK, { OptableConfig } from "@optable/web-sdk";
3+
import OptableSDK, { InitConfig } from "@optable/web-sdk";
44

55
const OptableContext = createContext<OptableSDK | null>(null);
66

77
// Sandbox configuration injected by webpack based on build environment (see demos/react/webpack.config.js)
88
declare global {
9-
const DCN_CONFIG: OptableConfig;
9+
const DCN_CONFIG: InitConfig;
1010
}
1111

1212
// Provide a global SDK instance across the application

lib/config.test.js

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { getConfig } from "./config";
2+
import globalConsent from "./core/regs/consent";
3+
4+
describe("getConfig", () => {
5+
it("returns the default config when no overrides are provided", () => {
6+
expect(getConfig({ host: "host", site: "site" })).toEqual({
7+
host: "host",
8+
site: "site",
9+
cookies: true,
10+
initPassport: true,
11+
consent: { deviceAccess: true, reg: null },
12+
});
13+
});
14+
15+
it("allows overriding all properties", () => {
16+
expect(
17+
getConfig({
18+
host: "host",
19+
site: "site",
20+
cookies: false,
21+
initPassport: false,
22+
consent: { static: { deviceAccess: true, reg: "us" } },
23+
})
24+
).toEqual({
25+
host: "host",
26+
site: "site",
27+
cookies: false,
28+
initPassport: false,
29+
consent: { deviceAccess: true, reg: "us" },
30+
});
31+
});
32+
33+
it("infers regulation and gathers consent when using cmpapi", () => {
34+
const spy = jest.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({
35+
resolvedOptions: () => ({
36+
timeZone: "America/New_York",
37+
}),
38+
}));
39+
40+
const config = getConfig({
41+
host: "host",
42+
site: "site",
43+
consent: { cmpapi: {} },
44+
});
45+
expect(config.consent).toEqual({ deviceAccess: true, reg: "us" });
46+
47+
spy.mockRestore();
48+
});
49+
});

lib/config.ts

+40-5
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,53 @@
1-
type OptableConfig = {
1+
import { getConsent, Consent, inferRegulation } from "./core/regs/consent";
2+
3+
type CMPApiConfig = {
4+
// An optional vendor ID from GVL (global vendor list) when interpretting TCF/GPP EU consent,
5+
// when not passed, defaults to publisher consent.
6+
tcfeuVendorID?: number;
7+
};
8+
9+
type InitConsent = {
10+
// A "cmpapi" configuration indicating that consent should be gathered from CMP apis.
11+
cmpapi?: CMPApiConfig;
12+
// A "static" consent object already collected by the publisher
13+
static?: Consent;
14+
};
15+
16+
type InitConfig = {
217
host: string;
318
site: string;
419
cookies?: boolean;
520
initPassport?: boolean;
21+
consent?: InitConsent;
22+
};
23+
24+
type ResolvedConfig = Required<Omit<InitConfig, "consent">> & {
25+
consent: Consent;
626
};
727

828
const DCN_DEFAULTS = {
929
cookies: true,
1030
initPassport: true,
31+
consent: { deviceAccess: true, reg: null },
1132
};
1233

13-
function getConfig(config: OptableConfig): Required<OptableConfig> {
14-
return { ...DCN_DEFAULTS, ...config };
34+
function getConfig(init: InitConfig): ResolvedConfig {
35+
const config: ResolvedConfig = {
36+
host: init.host,
37+
site: init.site,
38+
cookies: init.cookies ?? DCN_DEFAULTS.cookies,
39+
initPassport: init.initPassport ?? DCN_DEFAULTS.initPassport,
40+
consent: DCN_DEFAULTS.consent,
41+
};
42+
43+
if (init.consent?.static) {
44+
config.consent = init.consent.static;
45+
} else if (init.consent?.cmpapi) {
46+
config.consent = getConsent(inferRegulation(), init.consent.cmpapi);
47+
}
48+
49+
return config;
1550
}
1651

17-
export { OptableConfig, getConfig };
18-
export default OptableConfig;
52+
export type { InitConsent, CMPApiConfig, InitConfig, ResolvedConfig };
53+
export { getConfig };

lib/core/network.test.js

+20-3
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,28 @@ import { buildRequest } from "./network";
22
import { default as buildInfo } from "../build.json";
33

44
describe("buildRequest", () => {
5-
test("preserves path query string", () => {
6-
const dcn = { cookies: true, host: "host", site: "site" };
5+
it("preserves path query string", () => {
6+
const dcn = {
7+
cookies: true,
8+
host: "host",
9+
site: "site",
10+
consent: { reg: "can", gpp: "gpp", gppSectionIDs: [1, 2] },
11+
};
12+
713
const req = { method: "GET" };
814
const request = buildRequest("/path?query=string", dcn, req);
915

10-
expect(request.url).toBe(`https://host/site/path?query=string&osdk=web-${buildInfo.version}&cookies=yes`);
16+
const url = new URL(request.url);
17+
expect(url.host).toBe("host");
18+
expect(url.protocol).toBe("https:");
19+
expect(url.pathname).toBe("/site/path");
20+
expect([...url.searchParams.entries()]).toEqual([
21+
["query", "string"],
22+
["osdk", `web-${buildInfo.version}`],
23+
["reg", "can"],
24+
["gpp", "gpp"],
25+
["gpp_sid", "1,2"],
26+
["cookies", "yes"],
27+
]);
1128
});
1229
});

lib/core/network.ts

+19-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
1-
import type { OptableConfig } from "../config";
1+
import type { ResolvedConfig } from "../config";
22
import { default as buildInfo } from "../build.json";
33
import { LocalStorage } from "./storage";
44

5-
function buildRequest(path: string, config: Required<OptableConfig>, init?: RequestInit): Request {
5+
function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): Request {
66
const { site, host, cookies } = config;
77

88
const url = new URL(`${site}${path}`, `https://${host}`);
99
url.searchParams.set("osdk", `web-${buildInfo.version}`);
1010

11+
if (config.consent.reg) {
12+
url.searchParams.set("reg", config.consent.reg);
13+
}
14+
15+
if (config.consent.gpp) {
16+
url.searchParams.set("gpp", config.consent.gpp);
17+
}
18+
19+
if (config.consent.gppSectionIDs) {
20+
url.searchParams.set("gpp_sid", config.consent.gppSectionIDs.join(","));
21+
}
22+
23+
if (config.consent.tcf) {
24+
url.searchParams.set("tcf", config.consent.tcf);
25+
}
26+
1127
if (cookies) {
1228
url.searchParams.set("cookies", "yes");
1329
} else {
@@ -25,7 +41,7 @@ function buildRequest(path: string, config: Required<OptableConfig>, init?: Requ
2541
return request;
2642
}
2743

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

3147
const contentType = response.headers.get("Content-Type");

0 commit comments

Comments
 (0)