From 7887dbe1dc240b308dc6e767b4713895792f8476 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Wed, 2 Sep 2026 11:19:38 -0400 Subject: [PATCH 1/3] 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 }; From 8dde6d02e085b4977f633625be2467f946a69762 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Tue, 15 Sep 2026 11:52:16 -0400 Subject: [PATCH 2/3] eidCache: keep UID2 refresh material in a source-keyed refs sidecar --- lib/addons/uid2-refresh.md | 2 +- lib/addons/uid2-refresh.test.ts | 26 ++++- lib/addons/uid2-refresh.ts | 33 ++++--- lib/core/eid-cache.md | 22 +++-- lib/core/eid-cache.test.ts | 170 ++++++++++++++++++-------------- lib/core/eid-cache.ts | 100 ++++++++++++++----- 6 files changed, 220 insertions(+), 133 deletions(-) diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md index 3a3c4cf8..e5c528ce 100644 --- a/lib/addons/uid2-refresh.md +++ b/lib/addons/uid2-refresh.md @@ -32,6 +32,6 @@ import { applyUid2Refresh } from "@optable/web-sdk/lib/dist/addons/uid2-refresh" applyUid2Refresh(config, "uidapi.com", result); ``` -Applies a refresh outcome to the SDK's targeting cache. On `success`, the EID matching `source` gets its `uids` replaced with `[{ atype: 3, id: advertising_token }]` and its `_ref` rewritten from the response body. On `optout`, `invalid_token` or `expired_token`, the EID is removed. Any other error leaves the cache untouched — the cached token stays valid until `identity_expires`, and the next page load retries. Each write is followed by the `optable-targeting:change` event so consumers mirroring the cache (e.g. a pubProvidedId merge) can re-read it. A cache without a matching EID is left untouched. +Applies a refresh outcome to the SDK's targeting cache. On `success`, the EID matching `source` gets its `uids` replaced with `[{ atype: 3, id: advertising_token }]` and the cache's `refs` sidecar entry for that source rewritten from the response body. On `optout`, `invalid_token` or `expired_token`, the EID and its refs entry are removed. Any other error leaves the cache untouched — the cached token stays valid until `identity_expires`, and the next page load retries. Each write is followed by the `optable-targeting:change` event so consumers mirroring the cache (e.g. a pubProvidedId merge) can re-read it. A cache without a matching EID is left untouched. The stale-token refresh loop ships separately. diff --git a/lib/addons/uid2-refresh.test.ts b/lib/addons/uid2-refresh.test.ts index 5a3438ae..c9102e63 100644 --- a/lib/addons/uid2-refresh.test.ts +++ b/lib/addons/uid2-refresh.test.ts @@ -141,11 +141,12 @@ describe("applyUid2Refresh", () => { user: { data: [], eids: [ - { source: "uidapi.com", uids: [{ atype: 3, id: "OLD_TOKEN" }], _ref: OLD_REF }, + { source: "uidapi.com", uids: [{ atype: 3, id: "OLD_TOKEN" }] }, { source: "other.com", uids: [{ id: "KEEP" }] }, ], }, }, + refs: { "uidapi.com": OLD_REF }, } as unknown as TargetingResponse; new LocalStorage(config).setTargeting(targeting); } @@ -168,14 +169,14 @@ describe("applyUid2Refresh", () => { window.removeEventListener("optable-targeting:change", listener); }); - it("rewrites the EID's uids and _ref on success and sends the change event", () => { + it("rewrites the EID's uids and refs entry on success and sends the change event", () => { seedCache(); applyUid2Refresh(config, "uidapi.com", { status: "success", body: BODY }); const eids = cachedEids(); expect(eids).toHaveLength(2); expect(eids[0].uids).toEqual([{ atype: 3, id: BODY.advertising_token }]); - expect(eids[0]._ref).toEqual(BODY); + expect(new LocalStorage(config).getTargeting()?.refs).toEqual({ "uidapi.com": BODY }); expect(eids[1].source).toBe("other.com"); expect(events).toHaveLength(1); }); @@ -206,7 +207,7 @@ describe("applyUid2Refresh", () => { const publicEids = JSON.parse(localStorage.getItem("OPTABLE_RESOLVED") as string).ortb2.user.eids; expect(publicEids.map((e: { source: string }) => e.source)).toEqual(["uidapi.com", "other.com", "carryover.com"]); expect(publicEids[0].uids).toEqual([{ atype: 3, id: BODY.advertising_token }]); - expect(publicEids[0]._ref).toEqual(BODY); + expect(JSON.parse(localStorage.getItem("OPTABLE_RESOLVED") as string).refs).toEqual({ "uidapi.com": BODY }); }); it.each(["invalid_token", "expired_token"])("removes the EID on a definitive %s rejection", (reason) => { @@ -230,6 +231,23 @@ describe("applyUid2Refresh", () => { } ); + it("drops the opaque-keyed refs entry of an evicted EID from a wire-shaped copy", () => { + const targeting = { + ortb2: { + user: { + data: [], + eids: [{ source: "uidapi.com", uids: [{ atype: 3, id: "OLD_TOKEN", ext: { optable: { ref: "0" } } }] }], + }, + }, + refs: { "0": OLD_REF }, + } as unknown as TargetingResponse; + new LocalStorage(config).setTargeting(targeting); + + applyUid2Refresh(config, "uidapi.com", { status: "optout" }); + + expect(new LocalStorage(config).getTargeting()?.refs).toEqual({}); + }); + it("does nothing when the source is not in the cache", () => { seedCache(); applyUid2Refresh(config, "missing.com", { status: "optout" }); diff --git a/lib/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts index 80a31bd8..c332cc4f 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -1,4 +1,3 @@ -import type { EID } from "iab-openrtb/v26"; import { AgentType } from "iab-adcom"; import type { ResolvedConfig } from "../config"; import { isUid2RefData } from "../core/eid-cache"; @@ -11,8 +10,6 @@ type Uid2RefreshResult = | { status: "optout" } | { status: "error"; reason: string; message?: string }; -type RefreshableEID = EID & { _ref?: Uid2RefData }; - const UID2_REFRESH_ENDPOINT = "https://prod.uidapi.com/v2/token/refresh"; // Refresh responses are base64(12-byte nonce || AES-GCM ciphertext), keyed by @@ -77,9 +74,9 @@ const EVICTION_REASONS = new Set(["invalid_token", "expired_token"]); /** * Applies a refresh outcome to the targeting cache: success rewrites the - * matching EID in place, optout and definitive rejections evict it, any other - * error leaves the cache untouched for retry on the next page load. Sends the - * targeting change event after each write. + * matching EID and its refs sidecar entry, optout and definitive rejections + * evict both, any other error leaves the cache untouched for retry on the + * next page load. Sends the targeting change event after each write. */ function applyUid2Refresh(config: ResolvedConfig, source: string, result: Uid2RefreshResult): void { if (result.status === "error" && !EVICTION_REASONS.has(result.reason)) { @@ -87,7 +84,7 @@ function applyUid2Refresh(config: ResolvedConfig, source: string, result: Uid2Re } const updated = new LocalStorage(config).updateTargeting((cached) => { - const eids: RefreshableEID[] | undefined = cached?.ortb2?.user?.eids; + const eids = cached?.ortb2?.user?.eids; // If cache does not exist don't try to set. if (!eids) { return false; @@ -100,16 +97,20 @@ function applyUid2Refresh(config: ResolvedConfig, source: string, result: Uid2Re if (result.status === "success") { eids[idx].uids = [{ atype: AgentType.PERSON_BASED, id: result.body.advertising_token }]; - eids[idx]._ref = { - advertising_token: result.body.advertising_token, - refresh_token: result.body.refresh_token, - refresh_response_key: result.body.refresh_response_key, - refresh_from: result.body.refresh_from, - refresh_expires: result.body.refresh_expires, - identity_expires: result.body.identity_expires, - }; + cached.refs = { ...cached.refs, [source]: result.body }; } else { + // The private wire-shaped copy keys refs by opaque ref key, not source; + // drop the evicted EID's pointer target too so no refresh material for + // an opted-out user lingers in storage. + const refKey = (eids[idx].uids?.[0] as { ext?: { optable?: { ref?: string | number } } } | undefined)?.ext + ?.optable?.ref; eids.splice(idx, 1); + if (cached.refs) { + delete cached.refs[source]; + if (refKey !== undefined) { + delete cached.refs[refKey]; + } + } } return true; }); @@ -120,4 +121,4 @@ function applyUid2Refresh(config: ResolvedConfig, source: string, result: Uid2Re } export { refreshUid2Token, applyUid2Refresh, UID2_REFRESH_ENDPOINT }; -export type { Uid2RefData, Uid2RefreshResult, RefreshableEID }; +export type { Uid2RefData, Uid2RefreshResult }; diff --git a/lib/core/eid-cache.md b/lib/core/eid-cache.md index 0e85ea81..d143f495 100644 --- a/lib/core/eid-cache.md +++ b/lib/core/eid-cache.md @@ -20,19 +20,23 @@ localStorage.setItem("OPTABLE_RESOLVED", JSON.stringify(merged)); - 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`. +- The inputs are never mutated. The merged cache is built from copies. +- Cached EIDs are wire EIDs: refresh material never sits on them, so every consumer — RTD, `pubProvidedId`, anything else — can hand them to bidding as-is, with nothing to strip. ## 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. +Targeting responses carry UID2 refresh tokens in an opaque-keyed `refs` map, referenced from `uids[0].ext.optable.ref`. `mergeCache` validates those and stores them in the merged cache's `refs` sidecar keyed by EID `source`, dropping the `ext.optable.ref` pointer from the cached EIDs. Sources past their `refresh_from` are returned as `staleUid2s` (`{ source, ref }` pairs); refresh each with the [UID2 refresh addon](../addons/uid2-refresh.md)'s `refreshUid2Token(ref.refresh_token, ref.refresh_response_key)` and apply the outcome with `applyUid2Refresh`. -`_ref` is cache-only metadata: the RTD module strips it before EIDs reach bid requests. +A source's refs entry follows its EID: replaced when the source is re-resolved, dropped when it is evicted or the new response carries no ref for it. + +Caches written by earlier bundle versions carried refresh material as `_ref` on the EID; there is no read-side fallback for that shape. Such a cache simply cannot refresh its UID2 until the next targeting response repopulates the sidecar. ## 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`. | +| Export | Signature | Description | +| --------------- | ------------------------------------------------------ | -------------------------------------------------------------------- | +| `mergeCache` | `(newObj, oldObj, options?) => { merged, staleUid2s }` | Merge a fresh response into the cached one. | +| `resolveRefs` | `(eids, refs?) => Record` | Build a source-keyed refs map from a response's opaque-keyed one. | +| `getRefData` | `(cache, source) => Uid2RefData \| null` | The source's refs entry when it can drive a refresh. | +| `isUid2Stale` | `(cache, source?) => boolean` | True when the source's ref is past `refresh_from`. Defaults to UID2. | +| `isUid2RefData` | `(value) => value is Uid2RefData` | Shape guard for refresh material. | diff --git a/lib/core/eid-cache.test.ts b/lib/core/eid-cache.test.ts index 3a732c29..fd54c64e 100644 --- a/lib/core/eid-cache.test.ts +++ b/lib/core/eid-cache.test.ts @@ -16,45 +16,55 @@ const eid = (source: string, over: Record = {}) => ({ ...over, }); +// An EID pointing at the response refs map, the way the wire carries it. +const refEid = (source: string, refKey: string) => ({ + source, + uids: [{ id: `${source}-id`, ext: { optable: { ref: refKey } } }], +}); + 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"); + it("builds a source-keyed refs map from EIDs referencing the response refs", () => { 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(); + const bySource = resolveRefs([refEid("uidapi.com", "0"), eid("liveramp.com")] as any, refs as any); + expect(bySource).toEqual({ "uidapi.com": refs["0"] }); }); - 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(); + it("returns an empty map without a refs map", () => { + expect(resolveRefs([eid("uidapi.com")] as any)).toEqual({}); + }); + + it("ignores malformed and inherited-key refs", () => { + const bySource = resolveRefs( + [refEid("uidapi.com", "0"), refEid("id5-sync.com", "constructor")] as any, + { "0": { refresh_token: "rt" } } as any + ); + expect(bySource).toEqual({}); }); }); 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(); + it("returns the source's ref only when it can drive a refresh", () => { + expect(getRefData({ refs: { "uidapi.com": ref() } }, "uidapi.com")).not.toBeNull(); + expect(getRefData({ refs: { "uidapi.com": ref({ refresh_token: "" }) } }, "uidapi.com")).toBeNull(); + expect(getRefData({ refs: {} }, "uidapi.com")).toBeNull(); + expect(getRefData(null, "uidapi.com")).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); + expect(isUid2Stale({ refs: { "uidapi.com": ref({ refresh_from: Date.now() - 1 }) } })).toBe(true); + expect(isUid2Stale({ refs: { "uidapi.com": ref() } })).toBe(false); + expect(isUid2Stale({ refs: {} })).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); + expect(isUid2Stale({ refs: { "uidapi.com": ref({ refresh_from: 0 }) } })).toBe(true); }); }); @@ -79,88 +89,96 @@ describe("mergeCache", () => { 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 + mergeCache(cache([three]) 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("keeps merged EIDs wire-clean: refs live in the sidecar, not on EIDs", () => { + const wireRef = ref(); + const newCache = cache([refEid("uidapi.com", "0")], { refs: { "0": wireRef } }); + const { merged } = mergeCache(newCache as any, null); - it("tolerates null inputs", () => { - const { merged, staleUid2s } = mergeCache(null, undefined); - expect(merged.ortb2?.user?.eids).toEqual([]); - expect(staleUid2s).toEqual([]); + const uid2 = merged.ortb2?.user?.eids?.[0] as any; + expect(uid2._ref).toBeUndefined(); + expect(uid2.uids[0].ext).toBeUndefined(); + expect(getRefData(merged, "uidapi.com")).toEqual(wireRef); }); 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 wire = refEid("uidapi.com", "0"); + const newCache = cache([wire], { refs: { "0": ref() } }); - const { merged } = mergeCache(newCache as any, null); + 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(); + expect(wire.uids[0].ext.optable.ref).toBe("0"); + expect("_ref" in wire).toBe(false); }); - 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 }) })])) - ); + it("collects a stale UID2 from the sidecar after a JSON round-trip", () => { + const staleRef = ref({ refresh_from: Date.now() - 1 }); + const oldCache = JSON.parse(JSON.stringify(cache([eid("uidapi.com")], { refs: { "uidapi.com": staleRef } }))); 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"]); + expect(staleUid2s).toEqual([{ source: "uidapi.com", ref: staleRef }]); + expect(getRefData(merged, "uidapi.com")).toEqual(staleRef); + }); + + it("does not flag fresh UID2 refs or stale non-UID2 sources", () => { + const oldCache = cache([eid("uidapi.com"), eid("liveramp.com")], { + refs: { "uidapi.com": ref(), "liveramp.com": ref({ refresh_from: Date.now() - 1 }) }, + }); + const { staleUid2s } = mergeCache(null, oldCache as any); + expect(staleUid2s).toEqual([]); }); - 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: [] }]); + it("a new EID for a source replaces its refs entry, and eviction drops it", () => { + const oldCache = cache([eid("uidapi.com"), eid("id5-sync.com")], { + refs: { "uidapi.com": ref({ advertising_token: "old" }), "id5-sync.com": ref() }, + }); + const fresh = ref({ advertising_token: "fresh" }); + // uidapi.com re-resolved with a new ref; id5-sync.com revoked by empty uids. + const newCache = cache([refEid("uidapi.com", "0"), { source: "id5-sync.com", uids: [] }], { + refs: { "0": fresh }, + }); const { merged } = mergeCache(newCache as any, oldCache as any); - expect(merged.ortb2?.user?.eids?.map((e) => e.source)).toEqual(["liveramp.com"]); + expect(getRefData(merged, "uidapi.com")).toEqual(fresh); + expect(getRefData(merged, "id5-sync.com")).toBeNull(); + expect(merged.ortb2?.user?.eids?.map((e) => e.source)).toEqual(["uidapi.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" } } }); + it("a new EID without a ref clears the source's stale refs entry", () => { + const oldCache = cache([eid("uidapi.com")], { refs: { "uidapi.com": ref() } }); + const { merged } = mergeCache(cache([eid("uidapi.com")]) as any, oldCache as any); + expect(getRefData(merged, "uidapi.com")).toBeNull(); + }); + + it("pairs the refs entry with the EID actually kept when a response duplicates a source", () => { + const withRef = refEid("uidapi.com", "0"); + const withoutRef = eid("uidapi.com", { uids: [{ id: "kept" }] }); + const newCache = cache([withRef, withoutRef], { refs: { "0": ref() } }); + + const { merged } = mergeCache(newCache as any, null); + + expect(merged.ortb2?.user?.eids?.[0]?.uids?.[0]?.id).toBe("kept"); + expect(getRefData(merged, "uidapi.com")).toBeNull(); + }); - const { merged, staleUid2s } = mergeCache(newCache as any, null); + 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([]); - merged.ortb2?.user?.eids?.forEach((e) => expect(e._ref).toBeUndefined()); }); }); diff --git a/lib/core/eid-cache.ts b/lib/core/eid-cache.ts index 1b0ad7dd..603e955b 100644 --- a/lib/core/eid-cache.ts +++ b/lib/core/eid-cache.ts @@ -1,8 +1,12 @@ // 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 lives in the cache's refs sidecar, keyed by EID +// source — never on the EIDs themselves. Cached EIDs are wire EIDs: every +// consumer can hand them to bidding as-is, with nothing to strip. -// UID2 refresh material: the refresh response body, also carried in the -// targeting response refs map and on a cached EID's _ref. +// UID2 refresh material: the refresh response body, referenced from the +// targeting response refs map and carried in the cache's refs sidecar. type Uid2RefData = { advertising_token: string; refresh_token: string; @@ -19,16 +23,17 @@ type CachedEid = { 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[] } }; + // Refresh material sidecar. On a wire targeting response the map is keyed + // by opaque ref keys; in the merged cache it is keyed by EID source. refs?: Record; }; +type StaleUid2 = { source: string; ref: Uid2RefData }; + const UID2_SOURCE = "uidapi.com"; const DEFAULT_MAX_UIDS_PER_EID = 2; @@ -55,25 +60,28 @@ function refFor(eid: CachedEid, refs?: Record): Uid2RefData | u 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 { +// Builds the cache's source-keyed refs sidecar from a response's EIDs and its +// opaque-keyed refs map. +export function resolveRefs(eids: CachedEid[], refs?: Record): Record { + const bySource: Record = {}; eids.forEach((eid) => { const ref = refFor(eid, refs); if (ref) { - eid._ref = ref; + bySource[eid.source] = ref; } }); + return bySource; } -// 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; +// The source's ref data when the cache holds one usable for a refresh, else null. +export function getRefData(cache: ResolvedCache | null | undefined, source: string): Uid2RefData | null { + const ref = cache?.refs?.[source]; + return isUid2RefData(ref) && ref.refresh_token && ref.refresh_response_key ? ref : null; } // UID2 tokens carry a refresh_from timestamp; past it they need refreshing. -export function isUid2Stale(eid: CachedEid): boolean { - const ref = getRefData(eid); +export function isUid2Stale(cache: ResolvedCache | null | undefined, source: string = UID2_SOURCE): boolean { + const ref = getRefData(cache, source); if (!ref) return false; return Date.now() > (ref.refresh_from || 0); } @@ -82,42 +90,52 @@ export function mergeCache( newObj: ResolvedCache | null | undefined, oldObj: ResolvedCache | null | undefined, options?: { maxUidsPerEid?: number } -): { merged: ResolvedCache; staleUid2s: CachedEid[] } { +): { merged: ResolvedCache; staleUid2s: StaleUid2[] } { 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) }); + // Copies are wire-clean: capped uids, and the ref pointer into the response + // refs map is dropped since the sidecar replaces it. + const copyOf = (eid: CachedEid): CachedEid => ({ + ...eid, + uids: (eid.uids || []).slice(0, maxUids).map(stripRefPointer), + }); const newSources = new Set(newEids.map((e) => e.source)); const eidMap = new Map(); - const staleUid2s: CachedEid[] = []; + const refs: Record = {}; - // Carry over old EIDs whose source is not in the new response, keeping - // their existing _ref. + // Carry over old EIDs whose source is not in the new response, along with + // their refs entry. oldEids.forEach((eid) => { if (!eid.uids?.length) return; if (!newSources.has(eid.source)) { eidMap.set(eid.source, copyOf(eid)); + const ref = getRefData(oldObj, eid.source); + if (ref) { + refs[eid.source] = ref; + } } }); - // New EIDs overwrite old ones with the same source. + // New EIDs overwrite old ones with the same source. The refs entry is + // resolved from the same EID that is kept, so an EID and its refresh + // material always stay paired. newEids.forEach((eid) => { if (!eid.uids?.length) return; - const copy = copyOf(eid); + eidMap.set(eid.source, copyOf(eid)); const ref = refFor(eid, newObj?.refs); if (ref) { - copy._ref = ref; + refs[eid.source] = ref; + } else { + delete refs[eid.source]; } - eidMap.set(eid.source, copy); }); const mergedEids: CachedEid[] = []; + const staleUid2s: StaleUid2[] = []; eidMap.forEach((eid) => { - if (eid.source === UID2_SOURCE && isUid2Stale(eid)) { - staleUid2s.push(eid); - } mergedEids.push(eid); }); @@ -128,9 +146,37 @@ export function mergeCache( eids: mergedEids, }, }, + refs, }; + eidMap.forEach((eid) => { + if (eid.source === UID2_SOURCE && isUid2Stale(merged, eid.source)) { + staleUid2s.push({ source: eid.source, ref: refs[eid.source] }); + } + }); + return { merged, staleUid2s }; } -export type { CachedEid, ResolvedCache, Uid2RefData }; +type CachedUid = NonNullable[number]; + +function stripRefPointer(uid: CachedUid): CachedUid { + const optable = uid.ext?.optable; + if (!optable || optable.ref === undefined) return uid; + + const { ref: _dropped, ...restOptable } = optable; + const copy: CachedUid = { ...uid }; + if (Object.keys(restOptable).length) { + copy.ext = { ...uid.ext, optable: restOptable }; + } else { + const { optable: _optable, ...restExt } = uid.ext!; + if (Object.keys(restExt).length) { + copy.ext = restExt; + } else { + delete copy.ext; + } + } + return copy; +} + +export type { CachedEid, ResolvedCache, StaleUid2, Uid2RefData }; From 4e735e5118b0ca4f55235692d85d03221f027035 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Tue, 15 Sep 2026 12:01:22 -0400 Subject: [PATCH 3/3] uid2: hold targeting calls while a token refresh is in flight --- lib/addons/uid2-refresh.md | 13 +++++ lib/addons/uid2-refresh.ts | 14 +++++- lib/core/uid2-refresh-lock.test.ts | 76 ++++++++++++++++++++++++++++++ lib/core/uid2-refresh-lock.ts | 48 +++++++++++++++++++ lib/edge/targeting.ts | 6 +++ 5 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 lib/core/uid2-refresh-lock.test.ts create mode 100644 lib/core/uid2-refresh-lock.ts diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md index e5c528ce..1bcb6d31 100644 --- a/lib/addons/uid2-refresh.md +++ b/lib/addons/uid2-refresh.md @@ -34,4 +34,17 @@ applyUid2Refresh(config, "uidapi.com", result); Applies a refresh outcome to the SDK's targeting cache. On `success`, the EID matching `source` gets its `uids` replaced with `[{ atype: 3, id: advertising_token }]` and the cache's `refs` sidecar entry for that source rewritten from the response body. On `optout`, `invalid_token` or `expired_token`, the EID and its refs entry are removed. Any other error leaves the cache untouched — the cached token stays valid until `identity_expires`, and the next page load retries. Each write is followed by the `optable-targeting:change` event so consumers mirroring the cache (e.g. a pubProvidedId merge) can re-read it. A cache without a matching EID is left untouched. +While a `refreshUid2Token` call is in flight, targeting calls hold off (up to 2s, so a hung refresh cannot block targeting for the page), keeping a targeting response from interleaving with the refresh and pairing a fresh EID with a stale outcome. `refreshUid2Token` only covers its own operator round-trip; an orchestrator that does other async work between refreshing and applying should wrap the whole sequence with `trackUid2Refresh` from `core/uid2-refresh-lock` so the cache write is covered too: + +```js +import { trackUid2Refresh } from "@optable/web-sdk/lib/dist/core/uid2-refresh-lock"; + +await trackUid2Refresh(async () => { + const result = await refreshUid2Token(ref.refresh_token, ref.refresh_response_key); + applyUid2Refresh(config, source, result); +}); +``` + +A targeting call already in flight when the refresh starts can still overwrite the refresh outcome when its response lands — that ordering predates the lock, and the next merge cycle repairs it. + The stale-token refresh loop ships separately. diff --git a/lib/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts index c332cc4f..13d7cf4c 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -4,6 +4,7 @@ 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"; +import { trackUid2Refresh } from "../core/uid2-refresh-lock"; type Uid2RefreshResult = | { status: "success"; body: Uid2RefData } @@ -17,10 +18,21 @@ const UID2_REFRESH_ENDPOINT = "https://prod.uidapi.com/v2/token/refresh"; // // A response that cannot be decoded or decrypted throws; error policy stays // with the caller. -async function refreshUid2Token( +// +// Registered on the UID2 refresh lock, so targeting calls hold off while a +// refresh is in flight and cannot pair a fresh EID with a stale outcome. +function refreshUid2Token( refreshToken: string, refreshResponseKey: string, endpoint: string = UID2_REFRESH_ENDPOINT +): Promise { + return trackUid2Refresh(() => doRefreshUid2Token(refreshToken, refreshResponseKey, endpoint)); +} + +async function doRefreshUid2Token( + refreshToken: string, + refreshResponseKey: string, + endpoint: string ): Promise { const response = await fetch(endpoint, { method: "POST", diff --git a/lib/core/uid2-refresh-lock.test.ts b/lib/core/uid2-refresh-lock.test.ts new file mode 100644 index 00000000..2c8f1eab --- /dev/null +++ b/lib/core/uid2-refresh-lock.test.ts @@ -0,0 +1,76 @@ +import { trackUid2Refresh, uid2RefreshIdle } from "./uid2-refresh-lock"; + +function deferred() { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("uid2RefreshIdle", () => { + it("resolves immediately when no refresh is in flight", async () => { + await expect(uid2RefreshIdle()).resolves.toBeUndefined(); + }); + + it("waits until an in-flight refresh settles", async () => { + const refresh = deferred(); + const tracked = trackUid2Refresh(() => refresh.promise); + + let idle = false; + const waiter = uid2RefreshIdle().then(() => { + idle = true; + }); + await Promise.resolve(); + expect(idle).toBe(false); + + refresh.resolve("done"); + await expect(tracked).resolves.toBe("done"); + await waiter; + expect(idle).toBe(true); + }); + + it("waits for every overlapping refresh", async () => { + const a = deferred(); + const b = deferred(); + trackUid2Refresh(() => a.promise); + trackUid2Refresh(() => b.promise); + + let idle = false; + const waiter = uid2RefreshIdle().then(() => { + idle = true; + }); + + a.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(idle).toBe(false); + + b.resolve(); + await waiter; + expect(idle).toBe(true); + }); + + it("gives up after maxWaitMs when a refresh hangs", async () => { + const hung = deferred(); + trackUid2Refresh(() => hung.promise); + + const start = Date.now(); + await uid2RefreshIdle(20); + expect(Date.now() - start).toBeLessThan(1000); + + hung.resolve(); + }); + + it("releases when the refresh rejects", async () => { + const refresh = deferred(); + const tracked = trackUid2Refresh(() => refresh.promise); + const waiter = uid2RefreshIdle(); + + refresh.reject(new Error("boom")); + await expect(tracked).rejects.toThrow("boom"); + await expect(waiter).resolves.toBeUndefined(); + }); +}); diff --git a/lib/core/uid2-refresh-lock.ts b/lib/core/uid2-refresh-lock.ts new file mode 100644 index 00000000..12ea59c8 --- /dev/null +++ b/lib/core/uid2-refresh-lock.ts @@ -0,0 +1,48 @@ +// Serializes targeting calls against in-flight UID2 refreshes. A refresh +// rewrites the cached uidapi.com EID and its refs entry; a targeting response +// landing mid-refresh could pair a fresh EID (possibly resolved by another +// matcher) with the stale refresh outcome. Refreshes register here, and +// targeting waits for idle before it fires. + +let inFlight = 0; +let waiters: Array<() => void> = []; + +export async function trackUid2Refresh(refresh: () => Promise): Promise { + inFlight += 1; + try { + return await refresh(); + } finally { + inFlight -= 1; + if (inFlight === 0) { + const resolved = waiters; + waiters = []; + resolved.forEach((resolve) => resolve()); + } + } +} + +// Resolves once no refresh is in flight, or after maxWaitMs — the +// serialization is best-effort protection, and a hung refresh fetch must not +// block targeting for the rest of the page. Resolves immediately when idle. +export function uid2RefreshIdle(maxWaitMs?: number): Promise { + if (inFlight === 0) { + return Promise.resolve(); + } + return new Promise((resolve) => { + if (maxWaitMs === undefined) { + waiters.push(resolve); + return; + } + let settled = false; + const timeoutId = setTimeout(() => { + settled = true; + resolve(); + }, maxWaitMs); + waiters.push(() => { + if (!settled) { + clearTimeout(timeoutId); + resolve(); + } + }); + }); +} diff --git a/lib/edge/targeting.ts b/lib/edge/targeting.ts index b763dce7..eacce8f3 100644 --- a/lib/edge/targeting.ts +++ b/lib/edge/targeting.ts @@ -6,6 +6,7 @@ import { isBot } from "../addons/botDetection"; import * as ortb2 from "iab-openrtb/v26"; import * as adcom from "iab-adcom"; import { sendTargetingUpdateEvent } from "../core/events/cache-refresh"; +import { uid2RefreshIdle } from "../core/uid2-refresh-lock"; type Identifier = { id: string; @@ -45,6 +46,11 @@ type TargetingResponse = { const TARGETING_DONE_KEY = "OPTABLE_TARGETING_DONE"; async function Targeting(config: ResolvedConfig, req: TargetingRequest): Promise { + // Hold off while a UID2 refresh is rewriting the cache, so this response + // cannot interleave with the refresh outcome. Bounded: a hung refresh must + // not block targeting for the rest of the page. + await uid2RefreshIdle(2000); + const searchParams = new URLSearchParams(); req.ids.forEach((id) => searchParams.append("id", id)); req.hids.forEach((id) => searchParams.append("hid", id));