From 7887dbe1dc240b308dc6e767b4713895792f8476 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Wed, 2 Sep 2026 11:19:38 -0400 Subject: [PATCH] eidCache: add core module merging targeting responses into the rolling EID cache --- README.md | 14 ++++ lib/addons/uid2-refresh.ts | 26 +----- lib/core/eid-cache.md | 38 +++++++++ lib/core/eid-cache.test.ts | 166 +++++++++++++++++++++++++++++++++++++ lib/core/eid-cache.ts | 136 ++++++++++++++++++++++++++++++ 5 files changed, 356 insertions(+), 24 deletions(-) create mode 100644 lib/core/eid-cache.md create mode 100644 lib/core/eid-cache.test.ts create mode 100644 lib/core/eid-cache.ts diff --git a/README.md b/README.md index 49f26c7a..faab1d9f 100644 --- a/README.md +++ b/README.md @@ -1285,6 +1285,20 @@ window.optable.cmd = new OptableCommands(window.optable.cmd || []); For the page-side stub and behaviour details, see the [command queue addon README](lib/addons/commands.md). +## EID cache merge + +The EID cache merge module maintains a rolling EID cache across targeting and tokenize calls. New EIDs replace cached ones with the same source, sources absent from the new response are carried over, and UID2 EIDs past their refresh deadline are returned for the caller to refresh. + +```typescript +import { mergeCache } from "@optable/web-sdk/lib/dist/core/eid-cache"; + +const cached = JSON.parse(localStorage.getItem("OPTABLE_RESOLVED") || "null"); +const { merged, staleUid2s } = mergeCache(await sdk.targeting(), cached); +localStorage.setItem("OPTABLE_RESOLVED", JSON.stringify(merged)); +``` + +For the merge rules and UID2 ref handling, see the [EID cache README](lib/core/eid-cache.md). + ## 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/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts index 7bdb24b4..80a31bd8 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -1,20 +1,11 @@ import type { EID } from "iab-openrtb/v26"; import { AgentType } from "iab-adcom"; import type { ResolvedConfig } from "../config"; +import { isUid2RefData } from "../core/eid-cache"; +import type { Uid2RefData } from "../core/eid-cache"; import { LocalStorage } from "../core/storage"; import { sendTargetingUpdateEvent } from "../core/events/cache-refresh"; -// UID2 refresh token response body. Also the shape carried on a cached EID's -// _ref, resolved from the targeting response refs map. -type Uid2RefData = { - advertising_token: string; - refresh_token: string; - refresh_response_key: string; - refresh_from: number; - refresh_expires: number; - identity_expires: number; -}; - type Uid2RefreshResult = | { status: "success"; body: Uid2RefData } | { status: "optout" } @@ -24,19 +15,6 @@ type RefreshableEID = EID & { _ref?: Uid2RefData }; const UID2_REFRESH_ENDPOINT = "https://prod.uidapi.com/v2/token/refresh"; -function isUid2RefData(body: unknown): body is Uid2RefData { - const b = body as Record | null | undefined; - return ( - !!b && - typeof b.advertising_token === "string" && - typeof b.refresh_token === "string" && - typeof b.refresh_response_key === "string" && - typeof b.refresh_from === "number" && - typeof b.refresh_expires === "number" && - typeof b.identity_expires === "number" - ); -} - // Refresh responses are base64(12-byte nonce || AES-GCM ciphertext), keyed by // the refresh_response_key issued alongside the refresh token. // diff --git a/lib/core/eid-cache.md b/lib/core/eid-cache.md new file mode 100644 index 00000000..0e85ea81 --- /dev/null +++ b/lib/core/eid-cache.md @@ -0,0 +1,38 @@ +# EID Cache Merge + +Merge helpers for wrappers that keep a rolling EID cache (typically the `OPTABLE_RESOLVED` key in `localStorage`) across targeting and tokenize calls. Each response only covers the identifiers it resolved, so the cache is merged rather than overwritten. + +## Usage + +```js +import { mergeCache } from "@optable/web-sdk/lib/dist/core/eid-cache"; + +const cached = JSON.parse(localStorage.getItem("OPTABLE_RESOLVED") || "null"); +const response = await sdk.targeting(); + +const { merged, staleUid2s } = mergeCache(response, cached, { maxUidsPerEid: 2 }); +localStorage.setItem("OPTABLE_RESOLVED", JSON.stringify(merged)); +``` + +## Merge rules + +- New EIDs replace cached ones with the same `source`. A new EID without `uids` evicts the cached one: the response revoked that source. +- Cached EIDs from sources absent in the new response are carried over. +- Each EID keeps at most `maxUidsPerEid` UIDs (default 2). +- `ortb2.user.data` comes from the new response, falling back to the cached one. +- The inputs are never mutated. The merged cache is built from copies, so a targeting response that is also fed to bidding never picks up `_ref` refresh material or truncated `uids`. + +## UID2 refresh material + +Targeting responses carry UID2 refresh tokens in a `refs` map, referenced from `uids[0].ext.optable.ref`. `mergeCache` validates and resolves those onto each merged EID as `_ref`, and returns UID2 EIDs past their `refresh_from` as `staleUid2s`. Refresh each with the [UID2 refresh addon](../addons/uid2-refresh.md)'s `refreshUid2Token(ref.refresh_token, ref.refresh_response_key)`; a ready-made stale-refresh loop ships in a follow-up. + +`_ref` is cache-only metadata: the RTD module strips it before EIDs reach bid requests. + +## API + +| Export | Signature | Description | +| ------------- | ------------------------------------------------------ | ------------------------------------------------------------- | +| `mergeCache` | `(newObj, oldObj, options?) => { merged, staleUid2s }` | Merge a fresh response into the cached one. | +| `resolveRefs` | `(eids, refs?) => void` | Stamp validated `refs` entries onto EIDs as `_ref`, in place. | +| `getRefData` | `(eid) => Uid2RefData \| null` | The EID's `_ref` when it can drive a refresh. | +| `isUid2Stale` | `(eid) => boolean` | True when the EID's ref is past `refresh_from`. | diff --git a/lib/core/eid-cache.test.ts b/lib/core/eid-cache.test.ts new file mode 100644 index 00000000..3a732c29 --- /dev/null +++ b/lib/core/eid-cache.test.ts @@ -0,0 +1,166 @@ +import { getRefData, isUid2Stale, mergeCache, resolveRefs } from "./eid-cache"; + +const ref = (over: Record = {}) => ({ + advertising_token: "adv", + refresh_token: "rt", + refresh_response_key: "rk", + refresh_from: Date.now() + 60_000, + refresh_expires: Date.now() + 120_000, + identity_expires: Date.now() + 120_000, + ...over, +}); + +const eid = (source: string, over: Record = {}) => ({ + source, + uids: [{ id: `${source}-id`, atype: 3 }], + ...over, +}); + +const cache = (eids: unknown[], over: Record = {}) => ({ + ortb2: { user: { data: [], eids } }, + ...over, +}); + +describe("resolveRefs", () => { + it("stamps ref data onto EIDs that reference the refs map", () => { + const uid2 = { source: "uidapi.com", uids: [{ id: "x", ext: { optable: { ref: "0" } } }] }; + const other = eid("liveramp.com"); + const refs = { "0": ref() }; + resolveRefs([uid2, other] as any, refs as any); + expect((uid2 as any)._ref).toBe(refs["0"]); + expect((other as any)._ref).toBeUndefined(); + }); + + it("is a no-op without a refs map", () => { + const e = eid("uidapi.com"); + expect(() => resolveRefs([e] as any)).not.toThrow(); + expect((e as any)._ref).toBeUndefined(); + }); +}); + +describe("getRefData", () => { + it("returns the ref only when it can drive a refresh", () => { + expect(getRefData(eid("uidapi.com", { _ref: ref() }) as any)).not.toBeNull(); + expect(getRefData(eid("uidapi.com", { _ref: ref({ refresh_token: "" }) }) as any)).toBeNull(); + expect(getRefData(eid("uidapi.com") as any)).toBeNull(); + }); +}); + +describe("isUid2Stale", () => { + it("is true past refresh_from and false before", () => { + expect(isUid2Stale(eid("uidapi.com", { _ref: ref({ refresh_from: Date.now() - 1 }) }) as any)).toBe(true); + expect(isUid2Stale(eid("uidapi.com", { _ref: ref() }) as any)).toBe(false); + expect(isUid2Stale(eid("uidapi.com") as any)).toBe(false); + }); + + it("treats a ref without refresh_from as stale", () => { + expect(isUid2Stale(eid("uidapi.com", { _ref: ref({ refresh_from: undefined }) }) as any)).toBe(true); + }); +}); + +describe("mergeCache", () => { + it("replaces cached EIDs by source and carries over the rest", () => { + const oldCache = cache([eid("uidapi.com", { uids: [{ id: "old" }] }), eid("liveramp.com")]); + const newCache = cache([eid("uidapi.com", { uids: [{ id: "new" }] }), eid("id5-sync.com")]); + const { merged } = mergeCache(newCache as any, oldCache as any); + const eids = merged.ortb2?.user?.eids || []; + expect(eids.map((e) => e.source).sort()).toEqual(["id5-sync.com", "liveramp.com", "uidapi.com"]); + expect(eids.find((e) => e.source === "uidapi.com")?.uids?.[0]?.id).toBe("new"); + }); + + it("drops EIDs without uids", () => { + const { merged } = mergeCache( + cache([eid("a", { uids: [] })]) as any, + cache([eid("b", { uids: undefined })]) as any + ); + expect(merged.ortb2?.user?.eids).toEqual([]); + }); + + it("truncates uids to the default of 2 and honors maxUidsPerEid", () => { + const three = eid("a", { uids: [{ id: "1" }, { id: "2" }, { id: "3" }] }); + expect(mergeCache(cache([three]) as any, null).merged.ortb2?.user?.eids?.[0]?.uids).toHaveLength(2); + const again = eid("a", { uids: [{ id: "1" }, { id: "2" }, { id: "3" }] }); + expect( + mergeCache(cache([again]) as any, null, { maxUidsPerEid: 1 }).merged.ortb2?.user?.eids?.[0]?.uids + ).toHaveLength(1); + }); + + it("resolves refs from the new response and collects stale UID2 EIDs", () => { + const uid2 = { source: "uidapi.com", uids: [{ id: "x", ext: { optable: { ref: "0" } } }] }; + const newCache = cache([uid2], { refs: { "0": ref({ refresh_from: Date.now() - 1 }) } }); + const { merged, staleUid2s } = mergeCache(newCache as any, null); + expect(staleUid2s).toHaveLength(1); + expect(staleUid2s[0].source).toBe("uidapi.com"); + expect(merged.ortb2?.user?.eids).toHaveLength(1); + }); + + it("does not flag fresh UID2 EIDs or stale non-UID2 sources", () => { + const freshUid2 = eid("uidapi.com", { _ref: ref() }); + const staleOther = eid("liveramp.com", { _ref: ref({ refresh_from: Date.now() - 1 }) }); + const { staleUid2s } = mergeCache(cache([freshUid2, staleOther]) as any, null); + expect(staleUid2s).toEqual([]); + }); + + it("prefers new user data and falls back to old", () => { + const oldCache = { ortb2: { user: { data: [{ old: true }], eids: [] } } }; + const newCache = { ortb2: { user: { data: [{ fresh: true }], eids: [] } } }; + expect(mergeCache(newCache as any, oldCache as any).merged.ortb2?.user?.data).toEqual([{ fresh: true }]); + expect(mergeCache({ ortb2: { user: { eids: [] } } } as any, oldCache as any).merged.ortb2?.user?.data).toEqual([ + { old: true }, + ]); + }); + + it("tolerates null inputs", () => { + const { merged, staleUid2s } = mergeCache(null, undefined); + expect(merged.ortb2?.user?.eids).toEqual([]); + expect(staleUid2s).toEqual([]); + }); + + it("does not mutate the caller's response", () => { + const uid2 = { + source: "uidapi.com", + uids: [{ id: "1", ext: { optable: { ref: "0" } } }, { id: "2" }, { id: "3" }], + }; + const newCache = cache([uid2], { refs: { "0": ref() } }); + + const { merged } = mergeCache(newCache as any, null); + + expect(uid2.uids).toHaveLength(3); + expect("_ref" in uid2).toBe(false); + const mergedEid = merged.ortb2?.user?.eids?.[0]; + expect(mergedEid?.uids).toHaveLength(2); + expect(mergedEid?._ref).toBeDefined(); + }); + + it("collects a stale UID2 carried over from the old cache after a JSON round-trip", () => { + const oldCache = JSON.parse( + JSON.stringify(cache([eid("uidapi.com", { _ref: ref({ refresh_from: Date.now() - 1 }) })])) + ); + + const { merged, staleUid2s } = mergeCache(cache([eid("liveramp.com")]) as any, oldCache); + + expect(staleUid2s).toHaveLength(1); + expect(staleUid2s[0]._ref?.refresh_token).toBe("rt"); + expect(merged.ortb2?.user?.eids?.map((e) => e.source).sort()).toEqual(["liveramp.com", "uidapi.com"]); + }); + + it("a new EID without uids evicts the cached EID for that source", () => { + const oldCache = cache([eid("uidapi.com"), eid("liveramp.com")]); + const newCache = cache([{ source: "uidapi.com", uids: [] }]); + + const { merged } = mergeCache(newCache as any, oldCache as any); + + expect(merged.ortb2?.user?.eids?.map((e) => e.source)).toEqual(["liveramp.com"]); + }); + + it("ignores malformed and inherited-key refs", () => { + const badShape = { source: "uidapi.com", uids: [{ id: "x", ext: { optable: { ref: "0" } } }] }; + const inherited = { source: "id5-sync.com", uids: [{ id: "y", ext: { optable: { ref: "constructor" } } }] }; + const newCache = cache([badShape, inherited], { refs: { "0": { refresh_token: "rt" } } }); + + const { merged, staleUid2s } = mergeCache(newCache as any, null); + + expect(staleUid2s).toEqual([]); + merged.ortb2?.user?.eids?.forEach((e) => expect(e._ref).toBeUndefined()); + }); +}); diff --git a/lib/core/eid-cache.ts b/lib/core/eid-cache.ts new file mode 100644 index 00000000..1b0ad7dd --- /dev/null +++ b/lib/core/eid-cache.ts @@ -0,0 +1,136 @@ +// Merges a fresh targeting or tokenize response into a wrapper's rolling EID +// cache. Merge rules are documented in eid-cache.md; inputs are never mutated. + +// UID2 refresh material: the refresh response body, also carried in the +// targeting response refs map and on a cached EID's _ref. +type Uid2RefData = { + advertising_token: string; + refresh_token: string; + refresh_response_key: string; + refresh_from: number; + refresh_expires: number; + identity_expires: number; +}; + +type CachedEid = { + source: string; + uids?: Array<{ + id?: string; + atype?: number; + ext?: { optable?: { ref?: string | number } }; + }>; + // UID2 refresh material resolved from the response refs map. Cache-only: + // the RTD module strips it before EIDs reach bid requests. + _ref?: Uid2RefData; +}; + +type ResolvedCache = { + ortb2?: { user?: { data?: unknown[]; eids?: CachedEid[] } }; + refs?: Record; +}; + +const UID2_SOURCE = "uidapi.com"; +const DEFAULT_MAX_UIDS_PER_EID = 2; + +export function isUid2RefData(value: unknown): value is Uid2RefData { + const v = value as Record | null | undefined; + return ( + !!v && + typeof v.advertising_token === "string" && + typeof v.refresh_token === "string" && + typeof v.refresh_response_key === "string" && + typeof v.refresh_from === "number" && + typeof v.refresh_expires === "number" && + typeof v.identity_expires === "number" + ); +} + +// The validated ref data an EID points at via uids[0].ext.optable.ref. +// Own-property lookup only: an inherited key like "constructor" must not resolve. +function refFor(eid: CachedEid, refs?: Record): Uid2RefData | undefined { + if (!refs) return undefined; + const refKey = eid.uids?.[0]?.ext?.optable?.ref; + if (refKey === undefined || !Object.prototype.hasOwnProperty.call(refs, refKey)) return undefined; + const ref = refs[refKey]; + return isUid2RefData(ref) ? ref : undefined; +} + +// Stamps validated ref data (UID2 refresh tokens) from the response refs map +// onto each EID as _ref, in place. +export function resolveRefs(eids: CachedEid[], refs?: Record): void { + eids.forEach((eid) => { + const ref = refFor(eid, refs); + if (ref) { + eid._ref = ref; + } + }); +} + +// The EID's ref data when it is usable for a refresh, else null. +export function getRefData(eid: CachedEid): Uid2RefData | null { + return eid._ref?.refresh_token && eid._ref?.refresh_response_key ? eid._ref : null; +} + +// UID2 tokens carry a refresh_from timestamp; past it they need refreshing. +export function isUid2Stale(eid: CachedEid): boolean { + const ref = getRefData(eid); + if (!ref) return false; + return Date.now() > (ref.refresh_from || 0); +} + +export function mergeCache( + newObj: ResolvedCache | null | undefined, + oldObj: ResolvedCache | null | undefined, + options?: { maxUidsPerEid?: number } +): { merged: ResolvedCache; staleUid2s: CachedEid[] } { + const oldEids = oldObj?.ortb2?.user?.eids || []; + const newEids = newObj?.ortb2?.user?.eids || []; + const maxUids = options?.maxUidsPerEid ?? DEFAULT_MAX_UIDS_PER_EID; + + const copyOf = (eid: CachedEid): CachedEid => ({ ...eid, uids: (eid.uids || []).slice(0, maxUids) }); + + const newSources = new Set(newEids.map((e) => e.source)); + const eidMap = new Map(); + const staleUid2s: CachedEid[] = []; + + // Carry over old EIDs whose source is not in the new response, keeping + // their existing _ref. + oldEids.forEach((eid) => { + if (!eid.uids?.length) return; + if (!newSources.has(eid.source)) { + eidMap.set(eid.source, copyOf(eid)); + } + }); + + // New EIDs overwrite old ones with the same source. + newEids.forEach((eid) => { + if (!eid.uids?.length) return; + const copy = copyOf(eid); + const ref = refFor(eid, newObj?.refs); + if (ref) { + copy._ref = ref; + } + eidMap.set(eid.source, copy); + }); + + const mergedEids: CachedEid[] = []; + eidMap.forEach((eid) => { + if (eid.source === UID2_SOURCE && isUid2Stale(eid)) { + staleUid2s.push(eid); + } + mergedEids.push(eid); + }); + + const merged: ResolvedCache = { + ortb2: { + user: { + data: newObj?.ortb2?.user?.data || oldObj?.ortb2?.user?.data || [], + eids: mergedEids, + }, + }, + }; + + return { merged, staleUid2s }; +} + +export type { CachedEid, ResolvedCache, Uid2RefData };