From e78afaa5e6c635aaf2fcaa78b26459e5379a5f71 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Tue, 15 Sep 2026 09:56:09 -0400 Subject: [PATCH 1/7] id5: add resolveId5 with QA flags, bounded live resolution and a dedicated cache --- README.md | 13 ++++ lib/core/id5.test.ts | 116 +++++++++++++++++++++++++++++++++++ lib/core/id5.ts | 141 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 lib/core/id5.test.ts create mode 100644 lib/core/id5.ts diff --git a/README.md b/README.md index 1dae590c..6b1615ed 100644 --- a/README.md +++ b/README.md @@ -1497,6 +1497,19 @@ setStaticMappings({ For the merge rules and a full wrapper example, see the [static mappings README](lib/core/staticMappings.md). +## ID5 resolution + +`resolveId5(partnerId, options?)` resolves an [ID5](https://id5.io/) user id, loading the ID5 API on demand: QA flags first, then a local 7-day cache (its own `OPTABLE_ID5` key), then a live resolution with ID5's own A/B holdout disabled. Live resolution is bounded (10s default, `timeoutMs` option) and resolves `null` on timeout, load failure, partner-id mismatch or the ID5 `"0"` placeholder. Pass the bot detection addon's `isBot` to skip live resolution for crawlers: + +```javascript +import { resolveId5 } from "@optable/web-sdk/lib/dist/core/id5"; +import { isBot } from "@optable/web-sdk/lib/dist/addons/botDetection"; + +const id5Id = await resolveId5(id5PartnerId, { isBot }); +``` + +The `optableResolveID5ID` and `optableResolveId5` [QA flags](#qa-and-debug-flags) short-circuit resolution with a test value. + ## Demo Pages The demo pages are working examples of both `identify` and `targeting` APIs, as well as an integration with the [Google Ad Manager 360](https://admanager.google.com/home/) ad server, enabling the targeting of ads served by GAM360 to audiences activated in the [Optable](https://optable.co/) DCN. diff --git a/lib/core/id5.test.ts b/lib/core/id5.test.ts new file mode 100644 index 00000000..674c88b5 --- /dev/null +++ b/lib/core/id5.test.ts @@ -0,0 +1,116 @@ +import { getCachedId5UserId, resolveId5, ID5_CACHE_KEY } from "./id5"; +import { resetFlags } from "./flags"; + +type Id5Mock = { + debug?: boolean; + init: jest.Mock; +}; + +function injectedScript(): HTMLScriptElement { + const script = document.head.querySelector("script[src*='id5-sync.com']") as HTMLScriptElement; + expect(script).not.toBeNull(); + return script; +} + +// Simulates the ID5 API loading and invoking onUpdate on an instance that +// returns the given user id under the given partner id. +function loadId5(partnerId: number | string, userId: string | undefined): Id5Mock { + const instance = { + config: { providedOptions: { partnerId } }, + getUserId: () => userId, + onUpdate: (cb: () => void) => { + cb(); + return instance; + }, + }; + const id5: Id5Mock = { init: jest.fn(() => instance) }; + (window as any).ID5 = id5; + injectedScript().onload?.(new Event("load")); + return id5; +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + resetFlags(); + document.head.querySelectorAll("script").forEach((s) => s.remove()); + delete (window as any).ID5; +}); + +describe("getCachedId5UserId", () => { + it("returns a cached id within the TTL and null past it", () => { + localStorage.setItem(ID5_CACHE_KEY, JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() })); + expect(getCachedId5UserId()).toBe("id5-x"); + + localStorage.setItem( + ID5_CACHE_KEY, + JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() - 8 * 24 * 60 * 60 * 1000 }) + ); + expect(getCachedId5UserId()).toBeNull(); + }); + + it("tolerates a malformed cache", () => { + localStorage.setItem(ID5_CACHE_KEY, "{nope"); + expect(getCachedId5UserId()).toBeNull(); + }); +}); + +describe("resolveId5", () => { + it("returns the QA id from optableResolveID5ID without loading the API", async () => { + sessionStorage.setItem("optableResolveID5ID", "qa-id5-value"); + resetFlags(); + await expect(resolveId5(42)).resolves.toBe("qa-id5-value"); + expect(document.head.querySelector("script")).toBeNull(); + }); + + it("returns the placeholder for optableResolveId5", async () => { + sessionStorage.setItem("optableResolveId5", "1"); + resetFlags(); + await expect(resolveId5(42)).resolves.toBe("ID5-QA"); + }); + + it("returns the cached id without loading the API", async () => { + localStorage.setItem(ID5_CACHE_KEY, JSON.stringify({ userId: "cached-id5", resolvedAt: Date.now() })); + await expect(resolveId5(42)).resolves.toBe("cached-id5"); + expect(document.head.querySelector("script")).toBeNull(); + }); + + it("skips live resolution for bots", async () => { + await expect(resolveId5(42, { isBot: () => true })).resolves.toBeNull(); + expect(document.head.querySelector("script")).toBeNull(); + }); + + it("resolves a live id and caches it", async () => { + const pending = resolveId5(42); + loadId5(42, "live-id5"); + + await expect(pending).resolves.toBe("live-id5"); + expect(getCachedId5UserId()).toBe("live-id5"); + }); + + it("rejects a partner id mismatch", async () => { + const pending = resolveId5(42); + loadId5(99, "live-id5"); + + await expect(pending).resolves.toBeNull(); + expect(getCachedId5UserId()).toBeNull(); + }); + + it("rejects the ID5 '0' placeholder", async () => { + const pending = resolveId5(42); + loadId5(42, "0"); + + await expect(pending).resolves.toBeNull(); + }); + + it("resolves null when the API fails to load", async () => { + const pending = resolveId5(42); + injectedScript().onerror?.(new Event("error")); + + await expect(pending).resolves.toBeNull(); + }); + + it("gives up after the timeout when onUpdate never fires", async () => { + await expect(resolveId5(42, { timeoutMs: 20 })).resolves.toBeNull(); + }); +}); diff --git a/lib/core/id5.ts b/lib/core/id5.ts new file mode 100644 index 00000000..747883ba --- /dev/null +++ b/lib/core/id5.ts @@ -0,0 +1,141 @@ +import { debugLog } from "./log"; +import { flagEnabled, getFlags } from "./flags"; + +// Resolves an ID5 user id, loading the ID5 API on demand. Resolution order: +// QA flags, then the local cache (7-day TTL, its own storage key — never +// piggybacked on cached EIDs), then a live resolution. ID5's own A/B holdout +// is disabled so every consented user gets an id. + +const ID5_API_URL = "https://cdn.id5-sync.com/api/1.0/id5-api.js"; +const ID5_CACHE_KEY = "OPTABLE_ID5"; +const ID5_TTL_MS = 7 * 24 * 60 * 60 * 1000; +// Live resolution is bounded: ID5's onUpdate is not guaranteed to fire, and +// callers await this before targeting. +const DEFAULT_TIMEOUT_MS = 10_000; + +type Id5Options = { + // Skip live resolution (returning null) when true — wire to the bot + // detection addon's isBot. + isBot?: () => boolean; + // Give up on live resolution after this long. Defaults to 10s. + timeoutMs?: number; +}; + +type Id5Instance = { + config?: { providedOptions?: { partnerId?: number | string } }; + getUserId: () => string | undefined; + onUpdate: (cb: () => void) => Id5Instance; +}; + +declare global { + interface Window { + ID5?: { + debug?: boolean; + init: (options: Record) => Id5Instance; + }; + } +} + +export function getCachedId5UserId(): string | null { + try { + const cached = JSON.parse(localStorage.getItem(ID5_CACHE_KEY) || "null"); + if (typeof cached?.userId === "string" && cached.userId && Date.now() - cached.resolvedAt < ID5_TTL_MS) { + return cached.userId; + } + } catch { + // Unparseable cache; resolve live. + } + return null; +} + +function cacheId5UserId(userId: string): void { + try { + localStorage.setItem(ID5_CACHE_KEY, JSON.stringify({ userId, resolvedAt: Date.now() })); + } catch { + // Storage unavailable; the id still resolves for this page. + } +} + +export function resolveId5(partnerId: number | string, options: Id5Options = {}): Promise { + // QA: inject a specific value, or a placeholder, without loading the API. + const qaId5 = getFlags().optableResolveID5ID; + if (qaId5 && qaId5 !== "1") { + debugLog("log", "ID5: using QA id"); + return Promise.resolve(qaId5); + } + if (flagEnabled("optableResolveId5")) { + debugLog("log", "ID5: using QA value"); + return Promise.resolve("ID5-QA"); + } + + const cached = getCachedId5UserId(); + if (cached) { + debugLog("log", "ID5: using cached value"); + return Promise.resolve(cached); + } + + if (options.isBot?.()) { + debugLog("log", "ID5: skipped (bot)"); + return Promise.resolve(null); + } + + return new Promise((resolve) => { + let settled = false; + const settle = (value: string | null) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + resolve(value); + }; + const timeoutId = setTimeout(() => { + debugLog("warn", "ID5: timed out"); + settle(null); + }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + + const script = document.createElement("script"); + script.src = ID5_API_URL; + script.onload = () => { + debugLog("log", "ID5 library init"); + if (!window.ID5) { + settle(null); + return; + } + if (flagEnabled("optableDebug")) { + window.ID5.debug = true; + } + + const instance = window.ID5.init({ + partnerId, + debugBypassConsent: flagEnabled("optableDisableConsent"), + abTesting: { enabled: false, controlGroupPct: 0 }, + }); + instance.onUpdate(() => { + if (instance.config?.providedOptions?.partnerId !== partnerId) { + debugLog( + "warn", + `ID5: partner id mismatch: ${instance.config?.providedOptions?.partnerId} instead of ${partnerId}` + ); + settle(null); + return; + } + + const id5Id = instance.getUserId(); + if (!id5Id || id5Id === "0") { + debugLog("log", "ID5: invalid value"); + settle(null); + return; + } + debugLog("log", `ID5: resolved ${id5Id}`); + cacheId5UserId(id5Id); + settle(id5Id); + }); + }; + script.onerror = () => { + debugLog("warn", "ID5: API load failed"); + settle(null); + }; + document.head.appendChild(script); + }); +} + +export { ID5_CACHE_KEY, ID5_API_URL }; From 68572e0c85067af372fb71e643a1dcd16110d192 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Tue, 15 Sep 2026 13:32:14 -0400 Subject: [PATCH 2/7] id5: dedupe concurrent resolutions, scope the cache by partner, harden QA and init paths --- README.md | 4 +- lib/core/id5.test.ts | 59 +++++++++++++++++++++---- lib/core/id5.ts | 100 +++++++++++++++++++++++++++---------------- 3 files changed, 117 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 6b1615ed..e1ee98b1 100644 --- a/README.md +++ b/README.md @@ -1508,7 +1508,9 @@ import { isBot } from "@optable/web-sdk/lib/dist/addons/botDetection"; const id5Id = await resolveId5(id5PartnerId, { isBot }); ``` -The `optableResolveID5ID` and `optableResolveId5` [QA flags](#qa-and-debug-flags) short-circuit resolution with a test value. +The `optableResolveID5ID` and `optableResolveId5` [QA flags](#qa-and-debug-flags) short-circuit resolution with a test value. Concurrent calls share one script load and resolution. + +Consent-gating the call is the caller's responsibility: ID5's own CMP integration covers TCF pages, but the resolved id is cached in `localStorage`, so don't call `resolveId5` when device access is not permitted. ## Demo Pages diff --git a/lib/core/id5.test.ts b/lib/core/id5.test.ts index 674c88b5..d8bf931f 100644 --- a/lib/core/id5.test.ts +++ b/lib/core/id5.test.ts @@ -39,14 +39,19 @@ beforeEach(() => { describe("getCachedId5UserId", () => { it("returns a cached id within the TTL and null past it", () => { - localStorage.setItem(ID5_CACHE_KEY, JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() })); - expect(getCachedId5UserId()).toBe("id5-x"); + localStorage.setItem(ID5_CACHE_KEY, JSON.stringify({ partnerId: 42, userId: "id5-x", resolvedAt: Date.now() })); + expect(getCachedId5UserId(42)).toBe("id5-x"); localStorage.setItem( ID5_CACHE_KEY, - JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() - 8 * 24 * 60 * 60 * 1000 }) + JSON.stringify({ partnerId: 42, userId: "id5-x", resolvedAt: Date.now() - 8 * 24 * 60 * 60 * 1000 }) ); - expect(getCachedId5UserId()).toBeNull(); + expect(getCachedId5UserId(42)).toBeNull(); + }); + + it("does not serve another partner's cached id", () => { + localStorage.setItem(ID5_CACHE_KEY, JSON.stringify({ partnerId: 42, userId: "id5-x", resolvedAt: Date.now() })); + expect(getCachedId5UserId(99)).toBeNull(); }); it("tolerates a malformed cache", () => { @@ -63,6 +68,14 @@ describe("resolveId5", () => { expect(document.head.querySelector("script")).toBeNull(); }); + it("does not treat optableResolveID5ID=0 as an injected id", async () => { + sessionStorage.setItem("optableResolveID5ID", "0"); + resetFlags(); + const pending = resolveId5(42, { timeoutMs: 20 }); + expect(document.head.querySelector("script")).not.toBeNull(); + await expect(pending).resolves.toBeNull(); + }); + it("returns the placeholder for optableResolveId5", async () => { sessionStorage.setItem("optableResolveId5", "1"); resetFlags(); @@ -70,7 +83,10 @@ describe("resolveId5", () => { }); it("returns the cached id without loading the API", async () => { - localStorage.setItem(ID5_CACHE_KEY, JSON.stringify({ userId: "cached-id5", resolvedAt: Date.now() })); + localStorage.setItem( + ID5_CACHE_KEY, + JSON.stringify({ partnerId: 42, userId: "cached-id5", resolvedAt: Date.now() }) + ); await expect(resolveId5(42)).resolves.toBe("cached-id5"); expect(document.head.querySelector("script")).toBeNull(); }); @@ -80,12 +96,37 @@ describe("resolveId5", () => { expect(document.head.querySelector("script")).toBeNull(); }); - it("resolves a live id and caches it", async () => { + it("resolves a live id, caches it, and disables ID5's holdout", async () => { const pending = resolveId5(42); - loadId5(42, "live-id5"); + const id5 = loadId5(42, "live-id5"); await expect(pending).resolves.toBe("live-id5"); - expect(getCachedId5UserId()).toBe("live-id5"); + expect(getCachedId5UserId(42)).toBe("live-id5"); + expect(id5.init).toHaveBeenCalledWith( + expect.objectContaining({ partnerId: 42, abTesting: { enabled: false, controlGroupPct: 0 } }) + ); + }); + + it("shares one script load between concurrent callers", async () => { + const first = resolveId5(42); + const second = resolveId5(42); + loadId5(42, "live-id5"); + + await expect(first).resolves.toBe("live-id5"); + await expect(second).resolves.toBe("live-id5"); + expect(document.head.querySelectorAll("script[src*='id5-sync.com']")).toHaveLength(1); + }); + + it("settles null when ID5.init throws instead of stalling to the timeout", async () => { + const pending = resolveId5(42); + (window as any).ID5 = { + init: () => { + throw new Error("bad partner"); + }, + }; + injectedScript().onload?.(new Event("load")); + + await expect(pending).resolves.toBeNull(); }); it("rejects a partner id mismatch", async () => { @@ -93,7 +134,7 @@ describe("resolveId5", () => { loadId5(99, "live-id5"); await expect(pending).resolves.toBeNull(); - expect(getCachedId5UserId()).toBeNull(); + expect(getCachedId5UserId(42)).toBeNull(); }); it("rejects the ID5 '0' placeholder", async () => { diff --git a/lib/core/id5.ts b/lib/core/id5.ts index 747883ba..c7956472 100644 --- a/lib/core/id5.ts +++ b/lib/core/id5.ts @@ -36,9 +36,14 @@ declare global { } } -export function getCachedId5UserId(): string | null { +// The cache entry is partner-scoped: after a partner-id change, the previous +// partner's id is not served. +export function getCachedId5UserId(partnerId?: number | string): string | null { try { const cached = JSON.parse(localStorage.getItem(ID5_CACHE_KEY) || "null"); + if (partnerId !== undefined && cached?.partnerId !== partnerId) { + return null; + } if (typeof cached?.userId === "string" && cached.userId && Date.now() - cached.resolvedAt < ID5_TTL_MS) { return cached.userId; } @@ -48,18 +53,22 @@ export function getCachedId5UserId(): string | null { return null; } -function cacheId5UserId(userId: string): void { +function cacheId5UserId(partnerId: number | string, userId: string): void { try { - localStorage.setItem(ID5_CACHE_KEY, JSON.stringify({ userId, resolvedAt: Date.now() })); + localStorage.setItem(ID5_CACHE_KEY, JSON.stringify({ partnerId, userId, resolvedAt: Date.now() })); } catch { // Storage unavailable; the id still resolves for this page. } } +let pending: { partnerId: number | string; promise: Promise } | null = null; + export function resolveId5(partnerId: number | string, options: Id5Options = {}): Promise { // QA: inject a specific value, or a placeholder, without loading the API. + // A bare flag ("1") and "0" (the flag-disable convention, also ID5's + // invalid placeholder) do not count as injected values. const qaId5 = getFlags().optableResolveID5ID; - if (qaId5 && qaId5 !== "1") { + if (qaId5 && qaId5 !== "1" && qaId5 !== "0") { debugLog("log", "ID5: using QA id"); return Promise.resolve(qaId5); } @@ -68,7 +77,7 @@ export function resolveId5(partnerId: number | string, options: Id5Options = {}) return Promise.resolve("ID5-QA"); } - const cached = getCachedId5UserId(); + const cached = getCachedId5UserId(partnerId); if (cached) { debugLog("log", "ID5: using cached value"); return Promise.resolve(cached); @@ -79,7 +88,12 @@ export function resolveId5(partnerId: number | string, options: Id5Options = {}) return Promise.resolve(null); } - return new Promise((resolve) => { + // Concurrent callers share one script load and one resolution. + if (pending?.partnerId === partnerId) { + return pending.promise; + } + + const promise = new Promise((resolve) => { let settled = false; const settle = (value: string | null) => { if (settled) return; @@ -95,40 +109,46 @@ export function resolveId5(partnerId: number | string, options: Id5Options = {}) const script = document.createElement("script"); script.src = ID5_API_URL; script.onload = () => { - debugLog("log", "ID5 library init"); - if (!window.ID5) { - settle(null); - return; - } - if (flagEnabled("optableDebug")) { - window.ID5.debug = true; - } - - const instance = window.ID5.init({ - partnerId, - debugBypassConsent: flagEnabled("optableDisableConsent"), - abTesting: { enabled: false, controlGroupPct: 0 }, - }); - instance.onUpdate(() => { - if (instance.config?.providedOptions?.partnerId !== partnerId) { - debugLog( - "warn", - `ID5: partner id mismatch: ${instance.config?.providedOptions?.partnerId} instead of ${partnerId}` - ); + // An ID5.init throw must settle rather than stall until the timeout. + try { + debugLog("log", "ID5 library init"); + if (!window.ID5) { settle(null); return; } - - const id5Id = instance.getUserId(); - if (!id5Id || id5Id === "0") { - debugLog("log", "ID5: invalid value"); - settle(null); - return; + if (flagEnabled("optableDebug")) { + window.ID5.debug = true; } - debugLog("log", `ID5: resolved ${id5Id}`); - cacheId5UserId(id5Id); - settle(id5Id); - }); + + const instance = window.ID5.init({ + partnerId, + debugBypassConsent: flagEnabled("optableDisableConsent"), + abTesting: { enabled: false, controlGroupPct: 0 }, + }); + instance.onUpdate(() => { + if (instance.config?.providedOptions?.partnerId !== partnerId) { + debugLog( + "warn", + `ID5: partner id mismatch: ${instance.config?.providedOptions?.partnerId} instead of ${partnerId}` + ); + settle(null); + return; + } + + const id5Id = instance.getUserId(); + if (!id5Id || id5Id === "0") { + debugLog("log", "ID5: invalid value"); + settle(null); + return; + } + debugLog("log", `ID5: resolved ${id5Id}`); + cacheId5UserId(partnerId, id5Id); + settle(id5Id); + }); + } catch (err) { + debugLog("warn", "ID5: init failed", err); + settle(null); + } }; script.onerror = () => { debugLog("warn", "ID5: API load failed"); @@ -136,6 +156,14 @@ export function resolveId5(partnerId: number | string, options: Id5Options = {}) }; document.head.appendChild(script); }); + + pending = { partnerId, promise }; + promise.finally(() => { + if (pending?.promise === promise) { + pending = null; + } + }); + return promise; } export { ID5_CACHE_KEY, ID5_API_URL }; From c76f7bf5f228f99e6fe67b95a1224cd3583c9f6c Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Wed, 30 Sep 2026 11:20:56 -0400 Subject: [PATCH 3/7] id5: reuse a loaded API, gate the cache on device access, and key state by normalized partner id --- README.md | 8 ++++-- lib/core/flags.md | 4 +-- lib/core/id5.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++ lib/core/id5.ts | 65 +++++++++++++++++++++++++++++++------------- 4 files changed, 115 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e1ee98b1..8e2ce855 100644 --- a/README.md +++ b/README.md @@ -1508,9 +1508,13 @@ import { isBot } from "@optable/web-sdk/lib/dist/addons/botDetection"; const id5Id = await resolveId5(id5PartnerId, { isBot }); ``` -The `optableResolveID5ID` and `optableResolveId5` [QA flags](#qa-and-debug-flags) short-circuit resolution with a test value. Concurrent calls share one script load and resolution. +The `optableResolveID5ID` and `optableResolveId5` [QA flags](#qa-and-debug-flags) short-circuit resolution with a test value. Concurrent calls for the same partner share one script load and resolution, and an ID5 API already on the page is reused rather than loaded again. -Consent-gating the call is the caller's responsibility: ID5's own CMP integration covers TCF pages, but the resolved id is cached in `localStorage`, so don't call `resolveId5` when device access is not permitted. +ID5's own CMP integration covers TCF pages, but the resolved id is cached in `localStorage`. Pass `deviceAccess` to gate that cache; when it returns `false` the cache is neither read nor written and every call resolves live: + +```javascript +const id5Id = await resolveId5(id5PartnerId, { isBot, deviceAccess: () => consent.deviceAccess }); +``` ## Demo Pages diff --git a/lib/core/flags.md b/lib/core/flags.md index e9fb19f2..b0e8ade1 100644 --- a/lib/core/flags.md +++ b/lib/core/flags.md @@ -66,8 +66,8 @@ if (controlGroup === "1") { | `optableResolve1P` | wrapper code | Resolve using a first-party test identifier. | | `optableResolve3P` | wrapper code | Resolve using a third-party test IP. | | `optableEnableAnalytics` | wrapper code | Force analytics on, ignoring the sampling rate. | -| `optableResolveId5` | wrapper code | Return a placeholder ID5 value without loading the ID5 API. | -| `optableResolveID5ID` | wrapper code | Return a specific ID5 value without loading the ID5 API. | +| `optableResolveId5` | `resolveId5` | Return a placeholder ID5 value without loading the ID5 API. | +| `optableResolveID5ID` | `resolveId5` | Return a specific ID5 value without loading the ID5 API. | "Wrapper code" means the flag is recognised and persisted by the SDK, but acted on by the bundle built around it. Unknown query parameters are ignored — only the keys above are parsed. diff --git a/lib/core/id5.test.ts b/lib/core/id5.test.ts index d8bf931f..6df96956 100644 --- a/lib/core/id5.test.ts +++ b/lib/core/id5.test.ts @@ -155,3 +155,64 @@ describe("resolveId5", () => { await expect(resolveId5(42, { timeoutMs: 20 })).resolves.toBeNull(); }); }); + +describe("resolveId5 - reuse, consent and partner-id handling", () => { + it("reuses an ID5 API already on the page instead of injecting a second script", async () => { + const first = resolveId5(42, { timeoutMs: 20 }); + loadId5(42, undefined); + await expect(first).resolves.toBeNull(); + + const second = resolveId5(42, { timeoutMs: 20 }); + await expect(second).resolves.toBeNull(); + expect(document.head.querySelectorAll("script[src*='id5-sync.com']")).toHaveLength(1); + }); + + it("skips both cache read and write when deviceAccess denies storage", async () => { + localStorage.setItem( + ID5_CACHE_KEY, + JSON.stringify({ partnerId: 42, userId: "cached-id5", resolvedAt: Date.now() }) + ); + + const pending = resolveId5(42, { deviceAccess: () => false, timeoutMs: 20 }); + expect(document.head.querySelector("script")).not.toBeNull(); + loadId5(42, "live-id5"); + + await expect(pending).resolves.toBe("live-id5"); + expect(JSON.parse(localStorage.getItem(ID5_CACHE_KEY) || "null").userId).toBe("cached-id5"); + }); + + it("treats a numeric and a string partner id as the same partner", async () => { + const pending = resolveId5(42); + loadId5(42, "live-id5"); + + await expect(pending).resolves.toBe("live-id5"); + expect(getCachedId5UserId("42")).toBe("live-id5"); + }); + + it("keeps per-partner dedupe when partner ids interleave", async () => { + const first = resolveId5(1, { timeoutMs: 20 }); + const other = resolveId5(2, { timeoutMs: 20 }); + const again = resolveId5(1, { timeoutMs: 20 }); + + expect(again).toBe(first); + expect(document.head.querySelectorAll("script[src*='id5-sync.com']")).toHaveLength(2); + await Promise.all([first, other, again]); + }); + + it("caches an onUpdate that arrives after the timeout", async () => { + let fire = () => {}; + const instance: Record = { + config: { providedOptions: { partnerId: 42 } }, + getUserId: () => "late-id5", + onUpdate: (cb: () => void) => { + fire = cb; + return instance; + }, + }; + (window as any).ID5 = { init: () => instance }; + + await expect(resolveId5(42, { timeoutMs: 20 })).resolves.toBeNull(); + fire(); + expect(getCachedId5UserId(42)).toBe("late-id5"); + }); +}); diff --git a/lib/core/id5.ts b/lib/core/id5.ts index c7956472..74b5c66d 100644 --- a/lib/core/id5.ts +++ b/lib/core/id5.ts @@ -5,6 +5,9 @@ import { flagEnabled, getFlags } from "./flags"; // QA flags, then the local cache (7-day TTL, its own storage key — never // piggybacked on cached EIDs), then a live resolution. ID5's own A/B holdout // is disabled so every consented user gets an id. +// +// The cache is raw localStorage rather than LocalStorageProxy, which needs a +// ResolvedConfig this module is not given. Gate it with options.deviceAccess. const ID5_API_URL = "https://cdn.id5-sync.com/api/1.0/id5-api.js"; const ID5_CACHE_KEY = "OPTABLE_ID5"; @@ -19,6 +22,9 @@ type Id5Options = { isBot?: () => boolean; // Give up on live resolution after this long. Defaults to 10s. timeoutMs?: number; + // Gate the cache on device-access consent. Reads and writes are skipped + // when this returns false. Defaults to allowed. + deviceAccess?: () => boolean; }; type Id5Instance = { @@ -36,12 +42,16 @@ declare global { } } +// Partner ids are compared as strings throughout: a caller reading the id from +// a DOM attribute or JSON config passes "42" where another passed 42. +const partnerKey = (partnerId: number | string | undefined): string => String(partnerId); + // The cache entry is partner-scoped: after a partner-id change, the previous // partner's id is not served. export function getCachedId5UserId(partnerId?: number | string): string | null { try { const cached = JSON.parse(localStorage.getItem(ID5_CACHE_KEY) || "null"); - if (partnerId !== undefined && cached?.partnerId !== partnerId) { + if (partnerId !== undefined && partnerKey(cached?.partnerId) !== partnerKey(partnerId)) { return null; } if (typeof cached?.userId === "string" && cached.userId && Date.now() - cached.resolvedAt < ID5_TTL_MS) { @@ -55,13 +65,16 @@ export function getCachedId5UserId(partnerId?: number | string): string | null { function cacheId5UserId(partnerId: number | string, userId: string): void { try { - localStorage.setItem(ID5_CACHE_KEY, JSON.stringify({ partnerId, userId, resolvedAt: Date.now() })); + localStorage.setItem( + ID5_CACHE_KEY, + JSON.stringify({ partnerId: partnerKey(partnerId), userId, resolvedAt: Date.now() }) + ); } catch { // Storage unavailable; the id still resolves for this page. } } -let pending: { partnerId: number | string; promise: Promise } | null = null; +const pending = new Map>(); export function resolveId5(partnerId: number | string, options: Id5Options = {}): Promise { // QA: inject a specific value, or a placeholder, without loading the API. @@ -77,7 +90,8 @@ export function resolveId5(partnerId: number | string, options: Id5Options = {}) return Promise.resolve("ID5-QA"); } - const cached = getCachedId5UserId(partnerId); + const storageAllowed = options.deviceAccess?.() ?? true; + const cached = storageAllowed ? getCachedId5UserId(partnerId) : null; if (cached) { debugLog("log", "ID5: using cached value"); return Promise.resolve(cached); @@ -88,9 +102,11 @@ export function resolveId5(partnerId: number | string, options: Id5Options = {}) return Promise.resolve(null); } - // Concurrent callers share one script load and one resolution. - if (pending?.partnerId === partnerId) { - return pending.promise; + // Concurrent callers share one script load and one resolution, per partner. + const key = partnerKey(partnerId); + const inflight = pending.get(key); + if (inflight) { + return inflight; } const promise = new Promise((resolve) => { @@ -106,9 +122,7 @@ export function resolveId5(partnerId: number | string, options: Id5Options = {}) settle(null); }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); - const script = document.createElement("script"); - script.src = ID5_API_URL; - script.onload = () => { + const init = () => { // An ID5.init throw must settle rather than stall until the timeout. try { debugLog("log", "ID5 library init"); @@ -126,11 +140,9 @@ export function resolveId5(partnerId: number | string, options: Id5Options = {}) abTesting: { enabled: false, controlGroupPct: 0 }, }); instance.onUpdate(() => { - if (instance.config?.providedOptions?.partnerId !== partnerId) { - debugLog( - "warn", - `ID5: partner id mismatch: ${instance.config?.providedOptions?.partnerId} instead of ${partnerId}` - ); + const resolvedPartner = instance.config?.providedOptions?.partnerId; + if (partnerKey(resolvedPartner) !== key) { + debugLog("warn", `ID5: partner id mismatch: ${resolvedPartner} instead of ${partnerId}`); settle(null); return; } @@ -142,7 +154,11 @@ export function resolveId5(partnerId: number | string, options: Id5Options = {}) return; } debugLog("log", `ID5: resolved ${id5Id}`); - cacheId5UserId(partnerId, id5Id); + // An onUpdate arriving after the timeout still caches, so the next + // call gets the id rather than resolving again. + if (storageAllowed) { + cacheId5UserId(partnerId, id5Id); + } settle(id5Id); }); } catch (err) { @@ -150,6 +166,17 @@ export function resolveId5(partnerId: number | string, options: Id5Options = {}) settle(null); } }; + + // Reuse an API the page or an earlier resolution already loaded, rather + // than downloading it again. + if (window.ID5) { + init(); + return; + } + + const script = document.createElement("script"); + script.src = ID5_API_URL; + script.onload = init; script.onerror = () => { debugLog("warn", "ID5: API load failed"); settle(null); @@ -157,10 +184,10 @@ export function resolveId5(partnerId: number | string, options: Id5Options = {}) document.head.appendChild(script); }); - pending = { partnerId, promise }; + pending.set(key, promise); promise.finally(() => { - if (pending?.promise === promise) { - pending = null; + if (pending.get(key) === promise) { + pending.delete(key); } }); return promise; From c891909aa4ad989220dbe2da1a2daabcf5f49092 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Fri, 2 Oct 2026 12:07:41 -0400 Subject: [PATCH 4/7] id5: give each partner its own cache slot --- README.md | 2 +- lib/core/id5.test.ts | 50 +++++++++++++++++++++++++++++++------------- lib/core/id5.ts | 24 +++++++++------------ 3 files changed, 46 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 8e2ce855..8ee44204 100644 --- a/README.md +++ b/README.md @@ -1499,7 +1499,7 @@ For the merge rules and a full wrapper example, see the [static mappings README] ## ID5 resolution -`resolveId5(partnerId, options?)` resolves an [ID5](https://id5.io/) user id, loading the ID5 API on demand: QA flags first, then a local 7-day cache (its own `OPTABLE_ID5` key), then a live resolution with ID5's own A/B holdout disabled. Live resolution is bounded (10s default, `timeoutMs` option) and resolves `null` on timeout, load failure, partner-id mismatch or the ID5 `"0"` placeholder. Pass the bot detection addon's `isBot` to skip live resolution for crawlers: +`resolveId5(partnerId, options?)` resolves an [ID5](https://id5.io/) user id, loading the ID5 API on demand: QA flags first, then a local 7-day cache (its own key per partner, `OPTABLE_ID5:`), then a live resolution with ID5's own A/B holdout disabled. Live resolution is bounded (10s default, `timeoutMs` option) and resolves `null` on timeout, load failure, partner-id mismatch or the ID5 `"0"` placeholder. Pass the bot detection addon's `isBot` to skip live resolution for crawlers: ```javascript import { resolveId5 } from "@optable/web-sdk/lib/dist/core/id5"; diff --git a/lib/core/id5.test.ts b/lib/core/id5.test.ts index 6df96956..0163f6be 100644 --- a/lib/core/id5.test.ts +++ b/lib/core/id5.test.ts @@ -39,24 +39,24 @@ beforeEach(() => { describe("getCachedId5UserId", () => { it("returns a cached id within the TTL and null past it", () => { - localStorage.setItem(ID5_CACHE_KEY, JSON.stringify({ partnerId: 42, userId: "id5-x", resolvedAt: Date.now() })); + localStorage.setItem(`${ID5_CACHE_KEY}:42`, JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() })); expect(getCachedId5UserId(42)).toBe("id5-x"); localStorage.setItem( - ID5_CACHE_KEY, - JSON.stringify({ partnerId: 42, userId: "id5-x", resolvedAt: Date.now() - 8 * 24 * 60 * 60 * 1000 }) + `${ID5_CACHE_KEY}:42`, + JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() - 8 * 24 * 60 * 60 * 1000 }) ); expect(getCachedId5UserId(42)).toBeNull(); }); it("does not serve another partner's cached id", () => { - localStorage.setItem(ID5_CACHE_KEY, JSON.stringify({ partnerId: 42, userId: "id5-x", resolvedAt: Date.now() })); + localStorage.setItem(`${ID5_CACHE_KEY}:42`, JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() })); expect(getCachedId5UserId(99)).toBeNull(); }); it("tolerates a malformed cache", () => { - localStorage.setItem(ID5_CACHE_KEY, "{nope"); - expect(getCachedId5UserId()).toBeNull(); + localStorage.setItem(`${ID5_CACHE_KEY}:42`, "{nope"); + expect(getCachedId5UserId(42)).toBeNull(); }); }); @@ -83,10 +83,7 @@ describe("resolveId5", () => { }); it("returns the cached id without loading the API", async () => { - localStorage.setItem( - ID5_CACHE_KEY, - JSON.stringify({ partnerId: 42, userId: "cached-id5", resolvedAt: Date.now() }) - ); + localStorage.setItem(`${ID5_CACHE_KEY}:42`, JSON.stringify({ userId: "cached-id5", resolvedAt: Date.now() })); await expect(resolveId5(42)).resolves.toBe("cached-id5"); expect(document.head.querySelector("script")).toBeNull(); }); @@ -168,17 +165,14 @@ describe("resolveId5 - reuse, consent and partner-id handling", () => { }); it("skips both cache read and write when deviceAccess denies storage", async () => { - localStorage.setItem( - ID5_CACHE_KEY, - JSON.stringify({ partnerId: 42, userId: "cached-id5", resolvedAt: Date.now() }) - ); + localStorage.setItem(`${ID5_CACHE_KEY}:42`, JSON.stringify({ userId: "cached-id5", resolvedAt: Date.now() })); const pending = resolveId5(42, { deviceAccess: () => false, timeoutMs: 20 }); expect(document.head.querySelector("script")).not.toBeNull(); loadId5(42, "live-id5"); await expect(pending).resolves.toBe("live-id5"); - expect(JSON.parse(localStorage.getItem(ID5_CACHE_KEY) || "null").userId).toBe("cached-id5"); + expect(JSON.parse(localStorage.getItem(`${ID5_CACHE_KEY}:42`) || "null").userId).toBe("cached-id5"); }); it("treats a numeric and a string partner id as the same partner", async () => { @@ -216,3 +210,29 @@ describe("resolveId5 - reuse, consent and partner-id handling", () => { expect(getCachedId5UserId(42)).toBe("late-id5"); }); }); + +describe("resolveId5 - per-partner cache slots", () => { + it("caches two partner ids alongside each other", async () => { + (window as any).ID5 = { + init: (opts: Record) => { + const instance: Record = { + config: { providedOptions: { partnerId: opts.partnerId } }, + getUserId: () => `id-for-${opts.partnerId}`, + onUpdate: (cb: () => void) => { + cb(); + return instance; + }, + }; + return instance; + }, + }; + + await expect(resolveId5(1, { timeoutMs: 20 })).resolves.toBe("id-for-1"); + await expect(resolveId5(2, { timeoutMs: 20 })).resolves.toBe("id-for-2"); + + expect(getCachedId5UserId(1)).toBe("id-for-1"); + expect(getCachedId5UserId(2)).toBe("id-for-2"); + expect(localStorage.getItem(`${ID5_CACHE_KEY}:1`)).not.toBeNull(); + expect(localStorage.getItem(`${ID5_CACHE_KEY}:2`)).not.toBeNull(); + }); +}); diff --git a/lib/core/id5.ts b/lib/core/id5.ts index 74b5c66d..4f3d703a 100644 --- a/lib/core/id5.ts +++ b/lib/core/id5.ts @@ -2,8 +2,8 @@ import { debugLog } from "./log"; import { flagEnabled, getFlags } from "./flags"; // Resolves an ID5 user id, loading the ID5 API on demand. Resolution order: -// QA flags, then the local cache (7-day TTL, its own storage key — never -// piggybacked on cached EIDs), then a live resolution. ID5's own A/B holdout +// QA flags, then the local cache (7-day TTL, one storage key per partner — +// never piggybacked on cached EIDs), then a live resolution. ID5's own holdout // is disabled so every consented user gets an id. // // The cache is raw localStorage rather than LocalStorageProxy, which needs a @@ -11,7 +11,7 @@ import { flagEnabled, getFlags } from "./flags"; const ID5_API_URL = "https://cdn.id5-sync.com/api/1.0/id5-api.js"; const ID5_CACHE_KEY = "OPTABLE_ID5"; -const ID5_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const ID5_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days // Live resolution is bounded: ID5's onUpdate is not guaranteed to fire, and // callers await this before targeting. const DEFAULT_TIMEOUT_MS = 10_000; @@ -46,14 +46,13 @@ declare global { // a DOM attribute or JSON config passes "42" where another passed 42. const partnerKey = (partnerId: number | string | undefined): string => String(partnerId); -// The cache entry is partner-scoped: after a partner-id change, the previous -// partner's id is not served. -export function getCachedId5UserId(partnerId?: number | string): string | null { +// Each partner gets its own entry, so several partner ids on one page cache +// alongside each other instead of evicting one another. +const cacheKeyFor = (partnerId: number | string): string => `${ID5_CACHE_KEY}:${partnerKey(partnerId)}`; + +export function getCachedId5UserId(partnerId: number | string): string | null { try { - const cached = JSON.parse(localStorage.getItem(ID5_CACHE_KEY) || "null"); - if (partnerId !== undefined && partnerKey(cached?.partnerId) !== partnerKey(partnerId)) { - return null; - } + const cached = JSON.parse(localStorage.getItem(cacheKeyFor(partnerId)) || "null"); if (typeof cached?.userId === "string" && cached.userId && Date.now() - cached.resolvedAt < ID5_TTL_MS) { return cached.userId; } @@ -65,10 +64,7 @@ export function getCachedId5UserId(partnerId?: number | string): string | null { function cacheId5UserId(partnerId: number | string, userId: string): void { try { - localStorage.setItem( - ID5_CACHE_KEY, - JSON.stringify({ partnerId: partnerKey(partnerId), userId, resolvedAt: Date.now() }) - ); + localStorage.setItem(cacheKeyFor(partnerId), JSON.stringify({ userId, resolvedAt: Date.now() })); } catch { // Storage unavailable; the id still resolves for this page. } From 0ad0437d0d067ff36936cd04760cbf612018823f Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Fri, 2 Oct 2026 12:43:51 -0400 Subject: [PATCH 5/7] id5: lower the default live-resolution timeout to 2s --- README.md | 2 +- lib/core/id5.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8ee44204..9b0c5ee0 100644 --- a/README.md +++ b/README.md @@ -1499,7 +1499,7 @@ For the merge rules and a full wrapper example, see the [static mappings README] ## ID5 resolution -`resolveId5(partnerId, options?)` resolves an [ID5](https://id5.io/) user id, loading the ID5 API on demand: QA flags first, then a local 7-day cache (its own key per partner, `OPTABLE_ID5:`), then a live resolution with ID5's own A/B holdout disabled. Live resolution is bounded (10s default, `timeoutMs` option) and resolves `null` on timeout, load failure, partner-id mismatch or the ID5 `"0"` placeholder. Pass the bot detection addon's `isBot` to skip live resolution for crawlers: +`resolveId5(partnerId, options?)` resolves an [ID5](https://id5.io/) user id, loading the ID5 API on demand: QA flags first, then a local 7-day cache (its own key per partner, `OPTABLE_ID5:`), then a live resolution with ID5's own A/B holdout disabled. Live resolution is bounded (2s default, `timeoutMs` option) and resolves `null` on timeout, load failure, partner-id mismatch or the ID5 `"0"` placeholder. Pass the bot detection addon's `isBot` to skip live resolution for crawlers: ```javascript import { resolveId5 } from "@optable/web-sdk/lib/dist/core/id5"; diff --git a/lib/core/id5.ts b/lib/core/id5.ts index 4f3d703a..3a8001ae 100644 --- a/lib/core/id5.ts +++ b/lib/core/id5.ts @@ -14,13 +14,13 @@ const ID5_CACHE_KEY = "OPTABLE_ID5"; const ID5_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days // Live resolution is bounded: ID5's onUpdate is not guaranteed to fire, and // callers await this before targeting. -const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_TIMEOUT_MS = 2_000; type Id5Options = { // Skip live resolution (returning null) when true — wire to the bot // detection addon's isBot. isBot?: () => boolean; - // Give up on live resolution after this long. Defaults to 10s. + // Give up on live resolution after this long. Defaults to 2s. timeoutMs?: number; // Gate the cache on device-access consent. Reads and writes are skipped // when this returns false. Defaults to allowed. From a87bc7138868400c4e991dc4eef7ee8fe747cd77 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Fri, 2 Oct 2026 12:54:05 -0400 Subject: [PATCH 6/7] id5: document passing the SDK's own consent as the cache gate --- README.md | 11 +++++------ lib/core/id5.test.ts | 30 ++++++++++++++++++++++++++++++ lib/core/id5.ts | 8 ++++---- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9b0c5ee0..52807eb7 100644 --- a/README.md +++ b/README.md @@ -1505,16 +1505,15 @@ For the merge rules and a full wrapper example, see the [static mappings README] import { resolveId5 } from "@optable/web-sdk/lib/dist/core/id5"; import { isBot } from "@optable/web-sdk/lib/dist/addons/botDetection"; -const id5Id = await resolveId5(id5PartnerId, { isBot }); +const id5Id = await resolveId5(id5PartnerId, { + isBot, + deviceAccess: () => sdk.dcn.consent.deviceAccess, +}); ``` The `optableResolveID5ID` and `optableResolveId5` [QA flags](#qa-and-debug-flags) short-circuit resolution with a test value. Concurrent calls for the same partner share one script load and resolution, and an ID5 API already on the page is reused rather than loaded again. -ID5's own CMP integration covers TCF pages, but the resolved id is cached in `localStorage`. Pass `deviceAccess` to gate that cache; when it returns `false` the cache is neither read nor written and every call resolves live: - -```javascript -const id5Id = await resolveId5(id5PartnerId, { isBot, deviceAccess: () => consent.deviceAccess }); -``` +`deviceAccess` gates the cache: when it returns `false` the id is neither read from nor written to `localStorage`, and every call resolves live. It defaults to allowed, the same posture as the SDK's own default consent, so pass `sdk.dcn.consent.deviceAccess` as above. Omitting it on a page where the SDK is configured with `consent.cmpapi` leaves the ID5 cache ungated while every other SDK cache honours the CMP. ID5's own CMP integration separately gates its network call. ## Demo Pages diff --git a/lib/core/id5.test.ts b/lib/core/id5.test.ts index 0163f6be..ec59c7f4 100644 --- a/lib/core/id5.test.ts +++ b/lib/core/id5.test.ts @@ -1,4 +1,6 @@ import { getCachedId5UserId, resolveId5, ID5_CACHE_KEY } from "./id5"; +import OptableSDK from "../sdk"; +import { TEST_HOST, TEST_SITE } from "../test/mocks"; import { resetFlags } from "./flags"; type Id5Mock = { @@ -236,3 +238,31 @@ describe("resolveId5 - per-partner cache slots", () => { expect(localStorage.getItem(`${ID5_CACHE_KEY}:2`)).not.toBeNull(); }); }); + +describe("resolveId5 - documented consent wiring", () => { + it("accepts the SDK's own consent as the cache gate", async () => { + const sdk = new OptableSDK({ host: TEST_HOST, site: TEST_SITE, initPassport: false }); + (window as any).ID5 = { + init: () => { + const instance: Record = { + config: { providedOptions: { partnerId: 42 } }, + getUserId: () => "live-id5", + onUpdate: (cb: () => void) => { + cb(); + return instance; + }, + }; + return instance; + }, + }; + + const id5Id = await resolveId5(42, { + isBot: () => false, + deviceAccess: () => sdk.dcn.consent.deviceAccess, + timeoutMs: 20, + }); + + expect(id5Id).toBe("live-id5"); + expect(getCachedId5UserId(42)).toBe("live-id5"); + }); +}); diff --git a/lib/core/id5.ts b/lib/core/id5.ts index 3a8001ae..2b61ea11 100644 --- a/lib/core/id5.ts +++ b/lib/core/id5.ts @@ -6,8 +6,8 @@ import { flagEnabled, getFlags } from "./flags"; // never piggybacked on cached EIDs), then a live resolution. ID5's own holdout // is disabled so every consented user gets an id. // -// The cache is raw localStorage rather than LocalStorageProxy, which needs a -// ResolvedConfig this module is not given. Gate it with options.deviceAccess. +// The cache is raw localStorage gated by options.deviceAccess, not a +// LocalStorageProxy. Pass sdk.dcn.consent.deviceAccess so the two agree. const ID5_API_URL = "https://cdn.id5-sync.com/api/1.0/id5-api.js"; const ID5_CACHE_KEY = "OPTABLE_ID5"; @@ -22,8 +22,8 @@ type Id5Options = { isBot?: () => boolean; // Give up on live resolution after this long. Defaults to 2s. timeoutMs?: number; - // Gate the cache on device-access consent. Reads and writes are skipped - // when this returns false. Defaults to allowed. + // Gate the cache on device-access consent: pass sdk.dcn.consent.deviceAccess. + // Skips both read and write when false. Defaults to allowed, as the SDK does. deviceAccess?: () => boolean; }; From 9cb180f1cccc3275c43f5e324116b3dd90018055 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Mon, 5 Oct 2026 11:46:46 -0400 Subject: [PATCH 7/7] id5: require a device-access gate on every cache read and write --- README.md | 10 +++++++- lib/core/id5.test.ts | 55 +++++++++++++++++++++++++++++++++++--------- lib/core/id5.ts | 27 ++++++++++++++-------- 3 files changed, 71 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 52807eb7..c1a90d19 100644 --- a/README.md +++ b/README.md @@ -1513,7 +1513,15 @@ const id5Id = await resolveId5(id5PartnerId, { The `optableResolveID5ID` and `optableResolveId5` [QA flags](#qa-and-debug-flags) short-circuit resolution with a test value. Concurrent calls for the same partner share one script load and resolution, and an ID5 API already on the page is reused rather than loaded again. -`deviceAccess` gates the cache: when it returns `false` the id is neither read from nor written to `localStorage`, and every call resolves live. It defaults to allowed, the same posture as the SDK's own default consent, so pass `sdk.dcn.consent.deviceAccess` as above. Omitting it on a page where the SDK is configured with `consent.cmpapi` leaves the ID5 cache ungated while every other SDK cache honours the CMP. ID5's own CMP integration separately gates its network call. +`deviceAccess` gates the cache on both sides: when it returns `false` the id is neither read from nor written to `localStorage`, and every call resolves live. The gate is re-checked at write time, so consent withdrawn mid-resolution stops the write. On `resolveId5` it defaults to allowed, the same posture as the SDK's own default consent, so pass `sdk.dcn.consent.deviceAccess` as above — omitting it on a page configured with `consent.cmpapi` would leave the ID5 cache ungated while every other SDK cache honours the CMP. ID5's own CMP integration separately gates its network call. + +Reading the cache directly takes the same gate, as a required argument, so a cached user id cannot be read out of storage without stating a consent position: + +```javascript +import { getCachedId5UserId } from "@optable/web-sdk/lib/dist/core/id5"; + +const cached = getCachedId5UserId(id5PartnerId, () => sdk.dcn.consent.deviceAccess); +``` ## Demo Pages diff --git a/lib/core/id5.test.ts b/lib/core/id5.test.ts index ec59c7f4..44affce9 100644 --- a/lib/core/id5.test.ts +++ b/lib/core/id5.test.ts @@ -1,6 +1,9 @@ import { getCachedId5UserId, resolveId5, ID5_CACHE_KEY } from "./id5"; import OptableSDK from "../sdk"; import { TEST_HOST, TEST_SITE } from "../test/mocks"; + +// Consent granted, the posture the SDK itself defaults to. +const allow = () => true; import { resetFlags } from "./flags"; type Id5Mock = { @@ -42,23 +45,23 @@ beforeEach(() => { describe("getCachedId5UserId", () => { it("returns a cached id within the TTL and null past it", () => { localStorage.setItem(`${ID5_CACHE_KEY}:42`, JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() })); - expect(getCachedId5UserId(42)).toBe("id5-x"); + expect(getCachedId5UserId(42, allow)).toBe("id5-x"); localStorage.setItem( `${ID5_CACHE_KEY}:42`, JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() - 8 * 24 * 60 * 60 * 1000 }) ); - expect(getCachedId5UserId(42)).toBeNull(); + expect(getCachedId5UserId(42, allow)).toBeNull(); }); it("does not serve another partner's cached id", () => { localStorage.setItem(`${ID5_CACHE_KEY}:42`, JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() })); - expect(getCachedId5UserId(99)).toBeNull(); + expect(getCachedId5UserId(99, allow)).toBeNull(); }); it("tolerates a malformed cache", () => { localStorage.setItem(`${ID5_CACHE_KEY}:42`, "{nope"); - expect(getCachedId5UserId(42)).toBeNull(); + expect(getCachedId5UserId(42, allow)).toBeNull(); }); }); @@ -100,7 +103,7 @@ describe("resolveId5", () => { const id5 = loadId5(42, "live-id5"); await expect(pending).resolves.toBe("live-id5"); - expect(getCachedId5UserId(42)).toBe("live-id5"); + expect(getCachedId5UserId(42, allow)).toBe("live-id5"); expect(id5.init).toHaveBeenCalledWith( expect.objectContaining({ partnerId: 42, abTesting: { enabled: false, controlGroupPct: 0 } }) ); @@ -133,7 +136,7 @@ describe("resolveId5", () => { loadId5(99, "live-id5"); await expect(pending).resolves.toBeNull(); - expect(getCachedId5UserId(42)).toBeNull(); + expect(getCachedId5UserId(42, allow)).toBeNull(); }); it("rejects the ID5 '0' placeholder", async () => { @@ -182,7 +185,7 @@ describe("resolveId5 - reuse, consent and partner-id handling", () => { loadId5(42, "live-id5"); await expect(pending).resolves.toBe("live-id5"); - expect(getCachedId5UserId("42")).toBe("live-id5"); + expect(getCachedId5UserId("42", allow)).toBe("live-id5"); }); it("keeps per-partner dedupe when partner ids interleave", async () => { @@ -209,7 +212,7 @@ describe("resolveId5 - reuse, consent and partner-id handling", () => { await expect(resolveId5(42, { timeoutMs: 20 })).resolves.toBeNull(); fire(); - expect(getCachedId5UserId(42)).toBe("late-id5"); + expect(getCachedId5UserId(42, allow)).toBe("late-id5"); }); }); @@ -232,8 +235,8 @@ describe("resolveId5 - per-partner cache slots", () => { await expect(resolveId5(1, { timeoutMs: 20 })).resolves.toBe("id-for-1"); await expect(resolveId5(2, { timeoutMs: 20 })).resolves.toBe("id-for-2"); - expect(getCachedId5UserId(1)).toBe("id-for-1"); - expect(getCachedId5UserId(2)).toBe("id-for-2"); + expect(getCachedId5UserId(1, allow)).toBe("id-for-1"); + expect(getCachedId5UserId(2, allow)).toBe("id-for-2"); expect(localStorage.getItem(`${ID5_CACHE_KEY}:1`)).not.toBeNull(); expect(localStorage.getItem(`${ID5_CACHE_KEY}:2`)).not.toBeNull(); }); @@ -263,6 +266,36 @@ describe("resolveId5 - documented consent wiring", () => { }); expect(id5Id).toBe("live-id5"); - expect(getCachedId5UserId(42)).toBe("live-id5"); + expect(getCachedId5UserId(42, allow)).toBe("live-id5"); + }); +}); + +describe("getCachedId5UserId - consent gate", () => { + const deny = () => false; + + it("refuses to read a cached id when device access is denied", () => { + localStorage.setItem(`${ID5_CACHE_KEY}:42`, JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() })); + + expect(getCachedId5UserId(42, allow)).toBe("id5-x"); + expect(getCachedId5UserId(42, deny)).toBeNull(); + }); + + it("does not write a resolved id when device access is denied", async () => { + (window as any).ID5 = { + init: () => { + const instance: Record = { + config: { providedOptions: { partnerId: 42 } }, + getUserId: () => "live-id5", + onUpdate: (cb: () => void) => { + cb(); + return instance; + }, + }; + return instance; + }, + }; + + await expect(resolveId5(42, { deviceAccess: deny, timeoutMs: 20 })).resolves.toBe("live-id5"); + expect(localStorage.getItem(`${ID5_CACHE_KEY}:42`)).toBeNull(); }); }); diff --git a/lib/core/id5.ts b/lib/core/id5.ts index 2b61ea11..cd1ea8ef 100644 --- a/lib/core/id5.ts +++ b/lib/core/id5.ts @@ -6,8 +6,8 @@ import { flagEnabled, getFlags } from "./flags"; // never piggybacked on cached EIDs), then a live resolution. ID5's own holdout // is disabled so every consented user gets an id. // -// The cache is raw localStorage gated by options.deviceAccess, not a -// LocalStorageProxy. Pass sdk.dcn.consent.deviceAccess so the two agree. +// The cache is raw localStorage, not a LocalStorageProxy. Every read and +// write takes a deviceAccess gate; pass sdk.dcn.consent.deviceAccess. const ID5_API_URL = "https://cdn.id5-sync.com/api/1.0/id5-api.js"; const ID5_CACHE_KEY = "OPTABLE_ID5"; @@ -50,7 +50,13 @@ const partnerKey = (partnerId: number | string | undefined): string => String(pa // alongside each other instead of evicting one another. const cacheKeyFor = (partnerId: number | string): string => `${ID5_CACHE_KEY}:${partnerKey(partnerId)}`; -export function getCachedId5UserId(partnerId: number | string): string | null { +// deviceAccess is required rather than defaulted so a caller cannot read a +// user id out of storage without stating a consent position. +export function getCachedId5UserId(partnerId: number | string, deviceAccess: () => boolean): string | null { + if (!deviceAccess()) { + return null; + } + try { const cached = JSON.parse(localStorage.getItem(cacheKeyFor(partnerId)) || "null"); if (typeof cached?.userId === "string" && cached.userId && Date.now() - cached.resolvedAt < ID5_TTL_MS) { @@ -62,7 +68,11 @@ export function getCachedId5UserId(partnerId: number | string): string | null { return null; } -function cacheId5UserId(partnerId: number | string, userId: string): void { +function cacheId5UserId(partnerId: number | string, userId: string, deviceAccess: () => boolean): void { + if (!deviceAccess()) { + return; + } + try { localStorage.setItem(cacheKeyFor(partnerId), JSON.stringify({ userId, resolvedAt: Date.now() })); } catch { @@ -86,8 +96,9 @@ export function resolveId5(partnerId: number | string, options: Id5Options = {}) return Promise.resolve("ID5-QA"); } - const storageAllowed = options.deviceAccess?.() ?? true; - const cached = storageAllowed ? getCachedId5UserId(partnerId) : null; + // Re-evaluated at write time too, so consent withdrawn mid-resolution stops the write. + const deviceAccess = options.deviceAccess ?? (() => true); + const cached = getCachedId5UserId(partnerId, deviceAccess); if (cached) { debugLog("log", "ID5: using cached value"); return Promise.resolve(cached); @@ -152,9 +163,7 @@ export function resolveId5(partnerId: number | string, options: Id5Options = {}) debugLog("log", `ID5: resolved ${id5Id}`); // An onUpdate arriving after the timeout still caches, so the next // call gets the id rather than resolving again. - if (storageAllowed) { - cacheId5UserId(partnerId, id5Id); - } + cacheId5UserId(partnerId, id5Id, deviceAccess); settle(id5Id); }); } catch (err) {