diff --git a/README.md b/README.md index 1dae590c..c1a90d19 100644 --- a/README.md +++ b/README.md @@ -1497,6 +1497,32 @@ 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 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"; +import { isBot } from "@optable/web-sdk/lib/dist/addons/botDetection"; + +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. + +`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 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/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 new file mode 100644 index 00000000..44affce9 --- /dev/null +++ b/lib/core/id5.test.ts @@ -0,0 +1,301 @@ +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 = { + 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}:42`, JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() })); + 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, 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, allow)).toBeNull(); + }); + + it("tolerates a malformed cache", () => { + localStorage.setItem(`${ID5_CACHE_KEY}:42`, "{nope"); + expect(getCachedId5UserId(42, allow)).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("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(); + await expect(resolveId5(42)).resolves.toBe("ID5-QA"); + }); + + it("returns the cached id without loading the API", async () => { + 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(); + }); + + 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, caches it, and disables ID5's holdout", async () => { + const pending = resolveId5(42); + const id5 = loadId5(42, "live-id5"); + + await expect(pending).resolves.toBe("live-id5"); + expect(getCachedId5UserId(42, allow)).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 () => { + const pending = resolveId5(42); + loadId5(99, "live-id5"); + + await expect(pending).resolves.toBeNull(); + expect(getCachedId5UserId(42, allow)).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(); + }); +}); + +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}: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}:42`) || "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", allow)).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, allow)).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, 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(); + }); +}); + +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, 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 new file mode 100644 index 00000000..cd1ea8ef --- /dev/null +++ b/lib/core/id5.ts @@ -0,0 +1,201 @@ +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, 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, 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"; +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 = 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 2s. + timeoutMs?: number; + // 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; +}; + +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; + }; + } +} + +// 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); + +// 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)}`; + +// 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) { + return cached.userId; + } + } catch { + // Unparseable cache; resolve live. + } + return null; +} + +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 { + // Storage unavailable; the id still resolves for this page. + } +} + +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. + // 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" && qaId5 !== "0") { + debugLog("log", "ID5: using QA id"); + return Promise.resolve(qaId5); + } + if (flagEnabled("optableResolveId5")) { + debugLog("log", "ID5: using QA value"); + return Promise.resolve("ID5-QA"); + } + + // 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); + } + + if (options.isBot?.()) { + debugLog("log", "ID5: skipped (bot)"); + return Promise.resolve(null); + } + + // 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) => { + 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 init = () => { + // An ID5.init throw must settle rather than stall until the timeout. + try { + 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(() => { + const resolvedPartner = instance.config?.providedOptions?.partnerId; + if (partnerKey(resolvedPartner) !== key) { + debugLog("warn", `ID5: partner id mismatch: ${resolvedPartner} 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}`); + // An onUpdate arriving after the timeout still caches, so the next + // call gets the id rather than resolving again. + cacheId5UserId(partnerId, id5Id, deviceAccess); + settle(id5Id); + }); + } catch (err) { + debugLog("warn", "ID5: init failed", err); + 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); + }; + document.head.appendChild(script); + }); + + pending.set(key, promise); + promise.finally(() => { + if (pending.get(key) === promise) { + pending.delete(key); + } + }); + return promise; +} + +export { ID5_CACHE_KEY, ID5_API_URL };