diff --git a/README.md b/README.md index c92ad2e..05771d1 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,9 @@ When creating an instance of `OptableSDK`, you can pass an `InitConfig` object t - **`optableCacheTargeting` (string, defaults: `optable-cache:targeting`)** Local storage cache key used to store latest targeting response. +- **`forwardSignals` (boolean, default: `false`)** + When set to `true`, forwards soft device/browser signals (language, timezone, screen size, device memory, CPU cores) to the DCN in a `sig` request parameter. Also requires device access consent, so it is a no-op when consent is not granted. A signal the browser does not expose is omitted rather than sent empty. + These configurations allow fine-tuned control over how the `OptableSDK` interacts with the Optable DCN, ensuring compatibility with different environments and privacy settings. ## Usage Example diff --git a/lib/config.ts b/lib/config.ts index 2d7ab8e..864f651 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,3 +1,4 @@ +import { encodeBase64URL } from "./core/base64"; import { getConsent, inferRegulation } from "./core/regs/consent"; import type { CMPApiConfig, Consent } from "./core/regs/consent"; import type { PageContextConfig } from "./core/context"; @@ -65,6 +66,9 @@ type InitConfig = { abTests?: ABTestConfig[]; // Additional targeting signals to pass to the targeting call additionalTargetingSignals?: TargetingSignals; + // Forward soft device/browser signals in the 'sig' param. Opt in; also + // requires device access consent. + forwardSignals?: boolean; // Timeout hint for API calls (must include unit, e.g. '100ms', '2s', '1m') // When provided, the server will attempt to answer within the given time limit. // Some APIs like targeting may return partial responses depending at which stage the timeout occurred. @@ -106,6 +110,7 @@ type ResolvedConfig = { initContextual?: boolean | ((response: ContextualSegmentsResponse) => void); abTests?: ABTestConfig[]; additionalTargetingSignals?: TargetingSignals; + forwardSignals?: boolean; timeout?: string; insecure?: boolean; }; @@ -143,6 +148,7 @@ function getConfig(init: InitConfig): ResolvedConfig { initContextual: init.initContextual, abTests: init.abTests, additionalTargetingSignals: init.additionalTargetingSignals, + forwardSignals: init.forwardSignals, timeout: init.timeout, insecure: init.insecure, }; @@ -161,10 +167,7 @@ function generateSessionID(): string { crypto.getRandomValues(arr); // Equivalent to esnext arr.toBase64({ omitPadding: true, alphabet: "base64url" }) - return btoa(String.fromCharCode(...arr)) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/g, ""); + return encodeBase64URL(String.fromCharCode(...arr)); } export type { diff --git a/lib/core/base64.ts b/lib/core/base64.ts new file mode 100644 index 0000000..fdab001 --- /dev/null +++ b/lib/core/base64.ts @@ -0,0 +1,5 @@ +function encodeBase64URL(value: string): string { + return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +export { encodeBase64URL }; diff --git a/lib/core/network.test.js b/lib/core/network.test.js index 8dee159..0816434 100644 --- a/lib/core/network.test.js +++ b/lib/core/network.test.js @@ -48,19 +48,34 @@ describe("buildRequest", () => { expect(url.protocol).toBe("http:"); }); - it("omits credentials when device access isnt granted", () => { + it("omits credentials and device signals when device access isnt granted", () => { const dcn = { cookies: true, host: "host", site: "site", + forwardSignals: true, consent: { deviceAccess: false }, }; let request = buildRequest("/endpoint", dcn, { method: "GET" }); expect(request.credentials).toBe("omit"); + expect(new URL(request.url).searchParams.has("sig")).toBe(false); dcn.consent.deviceAccess = true; request = buildRequest("/endpoint", dcn, { method: "GET" }); expect(request.credentials).toBe("include"); + expect(new URL(request.url).searchParams.get("sig")).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("does not forward device signals unless opted in", () => { + const dcn = { + cookies: true, + host: "host", + site: "site", + consent: { deviceAccess: true }, + }; + + const request = buildRequest("/endpoint", dcn, { method: "GET" }); + expect(new URL(request.url).searchParams.has("sig")).toBe(false); }); }); diff --git a/lib/core/network.ts b/lib/core/network.ts index c5caf5b..50934e6 100644 --- a/lib/core/network.ts +++ b/lib/core/network.ts @@ -1,6 +1,7 @@ import type { ResolvedConfig } from "../config"; import { default as buildInfo } from "../build.json"; import { LocalStorage } from "./storage"; +import { deviceSignals } from "./signals"; function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): Request { const { host, cookies, insecure } = config; @@ -54,6 +55,13 @@ function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): url.searchParams.set("passport", pass ? pass : ""); } + if (config.forwardSignals && config.consent.deviceAccess) { + const sig = deviceSignals(); + if (sig) { + url.searchParams.set("sig", sig); + } + } + const requestInit: RequestInit = { ...init }; requestInit.credentials = config.consent.deviceAccess ? "include" : "omit"; diff --git a/lib/core/signals.test.ts b/lib/core/signals.test.ts new file mode 100644 index 0000000..56ae583 --- /dev/null +++ b/lib/core/signals.test.ts @@ -0,0 +1,104 @@ +import { readSignals, deviceSignals, encodeSignals } from "./signals"; + +const restores: Array<() => void> = []; + +// Shadows a host property for one test. jsdom defines most of these on the +// prototype, so an absent own-descriptor restores by deletion. +function stub(target: object, prop: string, value: unknown) { + const original = Object.getOwnPropertyDescriptor(target, prop); + Object.defineProperty(target, prop, { value, configurable: true, writable: true }); + restores.push(() => { + if (original) { + Object.defineProperty(target, prop, original); + } else { + delete (target as Record)[prop]; + } + }); +} + +function stubDevice(signals: { + languages?: readonly string[]; + timeZone?: string; + width?: number; + height?: number; + deviceMemory?: number; + cores?: number; +}) { + stub(window.navigator, "languages", signals.languages ?? []); + stub(window.navigator, "language", ""); + stub(window.navigator, "deviceMemory", signals.deviceMemory); + stub(window.navigator, "hardwareConcurrency", signals.cores); + stub(window.screen, "width", signals.width ?? 0); + stub(window.screen, "height", signals.height ?? 0); + jest + .spyOn(Intl, "DateTimeFormat") + .mockImplementation( + () => ({ resolvedOptions: () => ({ timeZone: signals.timeZone ?? "" }) }) as Intl.DateTimeFormat + ); +} + +const fullDevice = { + languages: ["en-US", "en"], + timeZone: "America/Toronto", + width: 3440, + height: 1440, + deviceMemory: 8, + cores: 8, +}; + +afterEach(() => { + while (restores.length) { + restores.pop()!(); + } + jest.restoreAllMocks(); +}); + +// Locks the wire format: the blob is decoded as base64url without padding, so a +// padding or alphabet slip breaks silently. +it("encodes signals to base64url without padding", () => { + const sig = encodeSignals({ + lang: "en-US,en", + tz: "America/Toronto", + scr: "3440x1440", + mem: "8", + cores: "8", + }); + + expect(sig).toBe("bGFuZz1lbi1VUyUyQ2VuJnR6PUFtZXJpY2ElMkZUb3JvbnRvJnNjcj0zNDQweDE0NDAmbWVtPTgmY29yZXM9OA"); + expect(sig).toMatch(/^[A-Za-z0-9_-]+$/); +}); + +it("forwards every signal the blob accepts, in a stable order", () => { + stubDevice(fullDevice); + + const signals = readSignals(); + expect(signals).toEqual({ + lang: "en-US,en", + tz: "America/Toronto", + scr: "3440x1440", + mem: "8", + cores: "8", + }); + expect(Object.keys(signals)).toEqual(["lang", "tz", "scr", "mem", "cores"]); +}); + +// An absent key means the signal was not forwarded, so an unreadable signal is +// omitted rather than sent empty, and it must not cost us the others. +it("omits signals that are unavailable, out of range, or throw", () => { + stubDevice({ ...fullDevice, deviceMemory: undefined, cores: 2048 }); + jest.spyOn(Intl, "DateTimeFormat").mockImplementation(() => { + throw new Error("blocked"); + }); + + const signals = readSignals(); + expect(signals).toEqual({ lang: "en-US,en", scr: "3440x1440" }); +}); + +it("returns an empty blob when no signal is available, and reuses it", () => { + stubDevice({}); + + expect(deviceSignals()).toBe(""); + + stubDevice(fullDevice); + expect(deviceSignals()).toBe(""); +}); diff --git a/lib/core/signals.ts b/lib/core/signals.ts new file mode 100644 index 0000000..e886cf6 --- /dev/null +++ b/lib/core/signals.ts @@ -0,0 +1,89 @@ +// The blob is built as query-string style key=value pairs, then base64url +// encoded without padding into a single opaque param, readable by design. +// +// An absent key means the signal was not forwarded, which is distinct from +// forwarding an empty value, so a reader that cannot read its signal returns +// undefined rather than an empty string. + +import { encodeBase64URL } from "./base64"; + +type SignalKey = "lang" | "tz" | "scr" | "mem" | "cores"; +type Signals = Partial>; +type NavigatorWithDeviceMemory = Navigator & { deviceMemory?: number }; +const NUMERIC_MAX = 1024; + +const READERS: Record string | undefined> = { + lang: () => { + const { languages, language } = navigator; + return languages?.length ? languages.join(",") : language || undefined; + }, + tz: () => Intl.DateTimeFormat().resolvedOptions().timeZone || undefined, + scr: () => { + const { width, height } = window.screen; + return isDimension(width) && isDimension(height) ? `${width}x${height}` : undefined; + }, + mem: () => numeric((navigator as NavigatorWithDeviceMemory).deviceMemory), + cores: () => numeric(navigator.hardwareConcurrency), +}; + +function isDimension(value: number): boolean { + return Number.isInteger(value) && value > 0; +} + +function numeric(value: number | undefined): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > NUMERIC_MAX) { + return undefined; + } + return `${value}`; +} + +function readSignals(): Signals { + const signals: Signals = {}; + + for (const key of Object.keys(READERS) as SignalKey[]) { + try { + const value = READERS[key](); + if (value) { + signals[key] = value; + } + } catch { + // The API is absent or blocked by a privacy shield; treat the signal as + // unavailable and keep the remaining readers running. + } + } + + return signals; +} + +function encodeSignals(signals: Signals): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(signals)) { + params.append(key, value); + } + + const query = params.toString(); + if (!query) { + return ""; + } + + try { + return encodeBase64URL(query); + } catch { + // A value the base64 alphabet cannot represent must not break the request; + // forward nothing instead. + return ""; + } +} + +// Every signal is fixed for the lifetime of the page, so the blob is read once +// and reused rather than rebuilt on each request. +let blob: string | undefined; + +// Returns the encoded `sig` blob to forward, or an empty string when no signal +// is available. +function deviceSignals(): string { + blob ??= encodeSignals(readSignals()); + return blob; +} + +export { deviceSignals, readSignals, encodeSignals };