From 05b640d8dc402818fbe4c4636676357dd425ac0e Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 13 Aug 2026 11:37:47 -0400 Subject: [PATCH 1/4] Signal collection for edge calls --- lib/core/network.test.js | 4 +- lib/core/network.ts | 8 ++++ lib/core/signals.test.ts | 99 ++++++++++++++++++++++++++++++++++++++++ lib/core/signals.ts | 80 ++++++++++++++++++++++++++++++++ lib/edge/resolve.test.js | 7 ++- lib/sdk.test.ts | 13 ++++-- 6 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 lib/core/signals.test.ts create mode 100644 lib/core/signals.ts diff --git a/lib/core/network.test.js b/lib/core/network.test.js index 8dee1598..bd9bbc9a 100644 --- a/lib/core/network.test.js +++ b/lib/core/network.test.js @@ -48,7 +48,7 @@ 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", @@ -57,10 +57,12 @@ describe("buildRequest", () => { }; 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_-]+$/); }); }); diff --git a/lib/core/network.ts b/lib/core/network.ts index c5caf5b8..f5d17b72 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.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 00000000..6db7c000 --- /dev/null +++ b/lib/core/signals.test.ts @@ -0,0 +1,99 @@ +import { collectSignals, 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("collects every signal the blob accepts, in a stable order", () => { + stubDevice(fullDevice); + + const signals = collectSignals(); + 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 "not collected", 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 = collectSignals(); + expect(signals).toEqual({ lang: "en-US,en", scr: "3440x1440" }); +}); + +it("returns an empty blob when no signal is available", () => { + stubDevice({}); + + expect(deviceSignals()).toBe(""); +}); diff --git a/lib/core/signals.ts b/lib/core/signals.ts new file mode 100644 index 00000000..24b52661 --- /dev/null +++ b/lib/core/signals.ts @@ -0,0 +1,80 @@ +// 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 "not collected", which is distinct from "collected as +// empty", so a collector that cannot read its signal returns undefined rather +// than an empty string. + +type SignalKey = "lang" | "tz" | "scr" | "mem" | "cores"; +type Signals = Partial>; +type NavigatorWithDeviceMemory = Navigator & { deviceMemory?: number }; +const NUMERIC_MAX = 1024; + +const COLLECTORS: 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 collectSignals(): Signals { + const signals: Signals = {}; + + for (const key of Object.keys(COLLECTORS) as SignalKey[]) { + try { + const value = COLLECTORS[key](); + if (value) { + signals[key] = value; + } + } catch { + // The API is absent or blocked by a privacy shield; treat the signal as + // not collected and keep the remaining collectors running. + } + } + + return signals; +} + +function encodeBase64URL(value: string): string { + return btoa(value) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +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(); + return query ? encodeBase64URL(query) : ""; +} + +// Returns the encoded `sig` blob, or an empty string when no signal could be +// collected. +function deviceSignals(): string { + return encodeSignals(collectSignals()); +} + +export { deviceSignals, collectSignals, encodeSignals }; +export type { SignalKey, Signals }; diff --git a/lib/edge/resolve.test.js b/lib/edge/resolve.test.js index db0b546f..fe30b9c0 100644 --- a/lib/edge/resolve.test.js +++ b/lib/edge/resolve.test.js @@ -2,6 +2,11 @@ import { getConfig } from "../config"; import { TEST_HOST, TEST_SITE, TEST_BASE_URL } from "../test/mocks"; import { parseResolveResponse, Resolve } from "./resolve"; +// buildRequest appends the device signal blob last, and its contents vary by +// environment. Anchored so it still pins everything ahead of it. +const withSig = (url) => + expect.stringMatching(new RegExp(`^${url.replace(/[.?*+^$[\]\\(){}|]/g, "\\$&")}(&sig=[A-Za-z0-9_-]+)?$`)); + describe("resolve", () => { test("forwards identifier when present", () => { const config = getConfig({ host: TEST_HOST, site: TEST_SITE, sessionID: "session" }); @@ -11,7 +16,7 @@ describe("resolve", () => { expect(fetchSpy).toHaveBeenCalledWith( expect.objectContaining({ method: "GET", - url: `${TEST_BASE_URL}/v1/resolve?id=id&osdk=web-0.0.0-experimental&sid=session&o=site&cookies=yes`, + url: withSig(`${TEST_BASE_URL}/v1/resolve?id=id&osdk=web-0.0.0-experimental&sid=session&o=site&cookies=yes`), }) ); diff --git a/lib/sdk.test.ts b/lib/sdk.test.ts index 2f0d3a4a..39b907a0 100644 --- a/lib/sdk.test.ts +++ b/lib/sdk.test.ts @@ -8,6 +8,11 @@ import { waitFor } from "./test/utils"; const defaultConsent = DCN_DEFAULTS.consent; +// buildRequest appends the device signal blob last, and its contents vary by +// environment. Anchored so it still pins everything ahead of it. +const withSig = (url: string) => + expect.stringMatching(new RegExp(`^${url.replace(/[.?*+^$[\]\\(){}|]/g, "\\$&")}(&sig=[A-Za-z0-9_-]+)?$`)); + describe("eid", () => { test("is correct", () => { const expected = "e:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"; @@ -244,7 +249,7 @@ describe("behavior testing of", () => { expect.objectContaining({ method: "POST", _bodyText: '["c:a1a335b8216658319f96a4b0c718557ba41dd1f5"]', - url: `${TEST_BASE_URL}/identify?osdk=web-0.0.0-experimental&sid=session&o=site&cookies=no&passport=`, + url: withSig(`${TEST_BASE_URL}/identify?osdk=web-0.0.0-experimental&sid=session&o=site&cookies=no&passport=`), }) ); @@ -254,7 +259,9 @@ describe("behavior testing of", () => { expect.objectContaining({ method: "POST", _bodyText: '["c:a1a335b8216658319f96a4b0c718557ba41dd1f6"]', - url: `${TEST_BASE_URL}/identify?osdk=web-0.0.0-experimental&sid=session&o=site&cookies=no&passport=PASSPORT`, + url: withSig( + `${TEST_BASE_URL}/identify?osdk=web-0.0.0-experimental&sid=session&o=site&cookies=no&passport=PASSPORT` + ), }) ); }); @@ -297,7 +304,7 @@ describe("behavior testing of", () => { expect.objectContaining({ method: "POST", _bodyText: '["c:a1a335b8216658319f96a4b0c718557ba41dd1f5"]', - url: `${TEST_BASE_URL}/identify?osdk=web-0.0.0-experimental&sid=session&o=site&cookies=yes`, + url: withSig(`${TEST_BASE_URL}/identify?osdk=web-0.0.0-experimental&sid=session&o=site&cookies=yes`), }) ); }); From 0cf9c32c1169e268bfabd2232847c1b6f9feb5ed Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 13 Aug 2026 11:46:54 -0400 Subject: [PATCH 2/4] rename --- lib/core/signals.test.ts | 12 ++++++------ lib/core/signals.ts | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/core/signals.test.ts b/lib/core/signals.test.ts index 6db7c000..88223b87 100644 --- a/lib/core/signals.test.ts +++ b/lib/core/signals.test.ts @@ -1,4 +1,4 @@ -import { collectSignals, deviceSignals, encodeSignals } from "./signals"; +import { readSignals, deviceSignals, encodeSignals } from "./signals"; const restores: Array<() => void> = []; @@ -66,10 +66,10 @@ it("encodes signals to base64url without padding", () => { expect(sig).toMatch(/^[A-Za-z0-9_-]+$/); }); -it("collects every signal the blob accepts, in a stable order", () => { +it("forwards every signal the blob accepts, in a stable order", () => { stubDevice(fullDevice); - const signals = collectSignals(); + const signals = readSignals(); expect(signals).toEqual({ lang: "en-US,en", tz: "America/Toronto", @@ -80,15 +80,15 @@ it("collects every signal the blob accepts, in a stable order", () => { expect(Object.keys(signals)).toEqual(["lang", "tz", "scr", "mem", "cores"]); }); -// An absent key means "not collected", so an unreadable signal is omitted rather -// than sent empty, and it must not cost us the others. +// 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 = collectSignals(); + const signals = readSignals(); expect(signals).toEqual({ lang: "en-US,en", scr: "3440x1440" }); }); diff --git a/lib/core/signals.ts b/lib/core/signals.ts index 24b52661..954e91df 100644 --- a/lib/core/signals.ts +++ b/lib/core/signals.ts @@ -1,16 +1,16 @@ // 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 "not collected", which is distinct from "collected as -// empty", so a collector that cannot read its signal returns undefined rather -// than an empty string. +// 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. type SignalKey = "lang" | "tz" | "scr" | "mem" | "cores"; type Signals = Partial>; type NavigatorWithDeviceMemory = Navigator & { deviceMemory?: number }; const NUMERIC_MAX = 1024; -const COLLECTORS: Record string | undefined> = { +const READERS: Record string | undefined> = { lang: () => { const { languages, language } = navigator; return languages?.length ? languages.join(",") : language || undefined; @@ -35,18 +35,18 @@ function numeric(value: number | undefined): string | undefined { return `${value}`; } -function collectSignals(): Signals { +function readSignals(): Signals { const signals: Signals = {}; - for (const key of Object.keys(COLLECTORS) as SignalKey[]) { + for (const key of Object.keys(READERS) as SignalKey[]) { try { - const value = COLLECTORS[key](); + const value = READERS[key](); if (value) { signals[key] = value; } } catch { // The API is absent or blocked by a privacy shield; treat the signal as - // not collected and keep the remaining collectors running. + // unavailable and keep the remaining readers running. } } @@ -70,11 +70,11 @@ function encodeSignals(signals: Signals): string { return query ? encodeBase64URL(query) : ""; } -// Returns the encoded `sig` blob, or an empty string when no signal could be -// collected. +// Returns the encoded `sig` blob to forward, or an empty string when no signal +// is available. function deviceSignals(): string { - return encodeSignals(collectSignals()); + return encodeSignals(readSignals()); } -export { deviceSignals, collectSignals, encodeSignals }; +export { deviceSignals, readSignals, encodeSignals }; export type { SignalKey, Signals }; From 014014d98b4e9c3ced59e9355b07b15c4fb69489 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 13 Aug 2026 12:09:56 -0400 Subject: [PATCH 3/4] prettier & improve code --- lib/config.ts | 6 ++---- lib/core/base64.ts | 5 +++++ lib/core/signals.test.ts | 13 +++++++++---- lib/core/signals.ts | 19 +++++++++---------- 4 files changed, 25 insertions(+), 18 deletions(-) create mode 100644 lib/core/base64.ts diff --git a/lib/config.ts b/lib/config.ts index 2d7ab8ef..bae22641 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"; @@ -161,10 +162,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 00000000..fdab0011 --- /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/signals.test.ts b/lib/core/signals.test.ts index 88223b87..56ae583c 100644 --- a/lib/core/signals.test.ts +++ b/lib/core/signals.test.ts @@ -30,9 +30,11 @@ function stubDevice(signals: { 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 - ); + jest + .spyOn(Intl, "DateTimeFormat") + .mockImplementation( + () => ({ resolvedOptions: () => ({ timeZone: signals.timeZone ?? "" }) }) as Intl.DateTimeFormat + ); } const fullDevice = { @@ -92,8 +94,11 @@ it("omits signals that are unavailable, out of range, or throw", () => { expect(signals).toEqual({ lang: "en-US,en", scr: "3440x1440" }); }); -it("returns an empty blob when no signal is available", () => { +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 index 954e91df..9c46dcc3 100644 --- a/lib/core/signals.ts +++ b/lib/core/signals.ts @@ -5,7 +5,9 @@ // forwarding an empty value, so a reader that cannot read its signal returns // undefined rather than an empty string. -type SignalKey = "lang" | "tz" | "scr" | "mem" | "cores"; +import { encodeBase64URL } from "./base64"; + +type SignalKey = "lang" | "tz" | "scr" | "mem" | "cores"; type Signals = Partial>; type NavigatorWithDeviceMemory = Navigator & { deviceMemory?: number }; const NUMERIC_MAX = 1024; @@ -53,13 +55,6 @@ function readSignals(): Signals { return signals; } -function encodeBase64URL(value: string): string { - return btoa(value) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/g, ""); -} - function encodeSignals(signals: Signals): string { const params = new URLSearchParams(); for (const [key, value] of Object.entries(signals)) { @@ -70,11 +65,15 @@ function encodeSignals(signals: Signals): string { return query ? encodeBase64URL(query) : ""; } +// 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 { - return encodeSignals(readSignals()); + blob ??= encodeSignals(readSignals()); + return blob; } export { deviceSignals, readSignals, encodeSignals }; -export type { SignalKey, Signals }; From dc2dce717811741c2cea63f117fc392519405dca Mon Sep 17 00:00:00 2001 From: mosherBT Date: Fri, 14 Aug 2026 12:11:11 -0400 Subject: [PATCH 4/4] address comments --- README.md | 3 +++ lib/config.ts | 5 +++++ lib/core/network.test.js | 13 +++++++++++++ lib/core/network.ts | 2 +- lib/core/signals.ts | 12 +++++++++++- lib/edge/resolve.test.js | 7 +------ lib/sdk.test.ts | 13 +++---------- 7 files changed, 37 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c92ad2ec..05771d19 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 bae22641..864f6514 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -66,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. @@ -107,6 +110,7 @@ type ResolvedConfig = { initContextual?: boolean | ((response: ContextualSegmentsResponse) => void); abTests?: ABTestConfig[]; additionalTargetingSignals?: TargetingSignals; + forwardSignals?: boolean; timeout?: string; insecure?: boolean; }; @@ -144,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, }; diff --git a/lib/core/network.test.js b/lib/core/network.test.js index bd9bbc9a..08164342 100644 --- a/lib/core/network.test.js +++ b/lib/core/network.test.js @@ -53,6 +53,7 @@ describe("buildRequest", () => { cookies: true, host: "host", site: "site", + forwardSignals: true, consent: { deviceAccess: false }, }; let request = buildRequest("/endpoint", dcn, { method: "GET" }); @@ -65,4 +66,16 @@ describe("buildRequest", () => { 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 f5d17b72..50934e63 100644 --- a/lib/core/network.ts +++ b/lib/core/network.ts @@ -55,7 +55,7 @@ function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): url.searchParams.set("passport", pass ? pass : ""); } - if (config.consent.deviceAccess) { + if (config.forwardSignals && config.consent.deviceAccess) { const sig = deviceSignals(); if (sig) { url.searchParams.set("sig", sig); diff --git a/lib/core/signals.ts b/lib/core/signals.ts index 9c46dcc3..e886cf65 100644 --- a/lib/core/signals.ts +++ b/lib/core/signals.ts @@ -62,7 +62,17 @@ function encodeSignals(signals: Signals): string { } const query = params.toString(); - return query ? encodeBase64URL(query) : ""; + 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 diff --git a/lib/edge/resolve.test.js b/lib/edge/resolve.test.js index fe30b9c0..db0b546f 100644 --- a/lib/edge/resolve.test.js +++ b/lib/edge/resolve.test.js @@ -2,11 +2,6 @@ import { getConfig } from "../config"; import { TEST_HOST, TEST_SITE, TEST_BASE_URL } from "../test/mocks"; import { parseResolveResponse, Resolve } from "./resolve"; -// buildRequest appends the device signal blob last, and its contents vary by -// environment. Anchored so it still pins everything ahead of it. -const withSig = (url) => - expect.stringMatching(new RegExp(`^${url.replace(/[.?*+^$[\]\\(){}|]/g, "\\$&")}(&sig=[A-Za-z0-9_-]+)?$`)); - describe("resolve", () => { test("forwards identifier when present", () => { const config = getConfig({ host: TEST_HOST, site: TEST_SITE, sessionID: "session" }); @@ -16,7 +11,7 @@ describe("resolve", () => { expect(fetchSpy).toHaveBeenCalledWith( expect.objectContaining({ method: "GET", - url: withSig(`${TEST_BASE_URL}/v1/resolve?id=id&osdk=web-0.0.0-experimental&sid=session&o=site&cookies=yes`), + url: `${TEST_BASE_URL}/v1/resolve?id=id&osdk=web-0.0.0-experimental&sid=session&o=site&cookies=yes`, }) ); diff --git a/lib/sdk.test.ts b/lib/sdk.test.ts index 39b907a0..2f0d3a4a 100644 --- a/lib/sdk.test.ts +++ b/lib/sdk.test.ts @@ -8,11 +8,6 @@ import { waitFor } from "./test/utils"; const defaultConsent = DCN_DEFAULTS.consent; -// buildRequest appends the device signal blob last, and its contents vary by -// environment. Anchored so it still pins everything ahead of it. -const withSig = (url: string) => - expect.stringMatching(new RegExp(`^${url.replace(/[.?*+^$[\]\\(){}|]/g, "\\$&")}(&sig=[A-Za-z0-9_-]+)?$`)); - describe("eid", () => { test("is correct", () => { const expected = "e:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"; @@ -249,7 +244,7 @@ describe("behavior testing of", () => { expect.objectContaining({ method: "POST", _bodyText: '["c:a1a335b8216658319f96a4b0c718557ba41dd1f5"]', - url: withSig(`${TEST_BASE_URL}/identify?osdk=web-0.0.0-experimental&sid=session&o=site&cookies=no&passport=`), + url: `${TEST_BASE_URL}/identify?osdk=web-0.0.0-experimental&sid=session&o=site&cookies=no&passport=`, }) ); @@ -259,9 +254,7 @@ describe("behavior testing of", () => { expect.objectContaining({ method: "POST", _bodyText: '["c:a1a335b8216658319f96a4b0c718557ba41dd1f6"]', - url: withSig( - `${TEST_BASE_URL}/identify?osdk=web-0.0.0-experimental&sid=session&o=site&cookies=no&passport=PASSPORT` - ), + url: `${TEST_BASE_URL}/identify?osdk=web-0.0.0-experimental&sid=session&o=site&cookies=no&passport=PASSPORT`, }) ); }); @@ -304,7 +297,7 @@ describe("behavior testing of", () => { expect.objectContaining({ method: "POST", _bodyText: '["c:a1a335b8216658319f96a4b0c718557ba41dd1f5"]', - url: withSig(`${TEST_BASE_URL}/identify?osdk=web-0.0.0-experimental&sid=session&o=site&cookies=yes`), + url: `${TEST_BASE_URL}/identify?osdk=web-0.0.0-experimental&sid=session&o=site&cookies=yes`, }) ); });