Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions lib/config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -106,6 +110,7 @@ type ResolvedConfig = {
initContextual?: boolean | ((response: ContextualSegmentsResponse) => void);
abTests?: ABTestConfig[];
additionalTargetingSignals?: TargetingSignals;
forwardSignals?: boolean;
timeout?: string;
insecure?: boolean;
};
Expand Down Expand Up @@ -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,
};
Expand All @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions lib/core/base64.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
function encodeBase64URL(value: string): string {
return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}

export { encodeBase64URL };
17 changes: 16 additions & 1 deletion lib/core/network.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
8 changes: 8 additions & 0 deletions lib/core/network.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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";

Expand Down
104 changes: 104 additions & 0 deletions lib/core/signals.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[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("");
});
89 changes: 89 additions & 0 deletions lib/core/signals.ts
Original file line number Diff line number Diff line change
@@ -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<Record<SignalKey, string>>;
type NavigatorWithDeviceMemory = Navigator & { deviceMemory?: number };
const NUMERIC_MAX = 1024;

const READERS: Record<SignalKey, () => 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 };
Loading