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.md b/lib/addons/uid2-refresh.md index 3a3c4cf8..1bcb6d31 100644 --- a/lib/addons/uid2-refresh.md +++ b/lib/addons/uid2-refresh.md @@ -32,6 +32,19 @@ 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. + +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.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 7bdb24b4..13d7cf4c 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -1,51 +1,38 @@ -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; -}; +import { trackUid2Refresh } from "../core/uid2-refresh-lock"; type Uid2RefreshResult = | { status: "success"; body: Uid2RefData } | { status: "optout" } | { status: "error"; reason: string; message?: string }; -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. // // 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", @@ -99,9 +86,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)) { @@ -109,7 +96,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; @@ -122,16 +109,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; }); @@ -142,4 +133,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 new file mode 100644 index 00000000..d143f495 --- /dev/null +++ b/lib/core/eid-cache.md @@ -0,0 +1,42 @@ +# 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. +- 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 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`. + +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?) => 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 new file mode 100644 index 00000000..fd54c64e --- /dev/null +++ b/lib/core/eid-cache.test.ts @@ -0,0 +1,184 @@ +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, +}); + +// 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("builds a source-keyed refs map from EIDs referencing the response refs", () => { + const refs = { "0": ref() }; + const bySource = resolveRefs([refEid("uidapi.com", "0"), eid("liveramp.com")] as any, refs as any); + expect(bySource).toEqual({ "uidapi.com": refs["0"] }); + }); + + 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 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({ 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({ refs: { "uidapi.com": ref({ refresh_from: 0 }) } })).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); + expect( + mergeCache(cache([three]) as any, null, { maxUidsPerEid: 1 }).merged.ortb2?.user?.eids?.[0]?.uids + ).toHaveLength(1); + }); + + 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); + + 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 wire = refEid("uidapi.com", "0"); + const newCache = cache([wire], { refs: { "0": ref() } }); + + mergeCache(newCache as any, null); + + expect(wire.uids[0].ext.optable.ref).toBe("0"); + expect("_ref" in wire).toBe(false); + }); + + 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).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 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(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("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(); + }); + + 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([]); + }); +}); diff --git a/lib/core/eid-cache.ts b/lib/core/eid-cache.ts new file mode 100644 index 00000000..603e955b --- /dev/null +++ b/lib/core/eid-cache.ts @@ -0,0 +1,182 @@ +// 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, referenced from the +// targeting response refs map and carried in the cache's refs sidecar. +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 } }; + }>; +}; + +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; + +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; +} + +// 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) { + bySource[eid.source] = ref; + } + }); + return bySource; +} + +// 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(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); +} + +export function mergeCache( + newObj: ResolvedCache | null | undefined, + oldObj: ResolvedCache | null | undefined, + options?: { maxUidsPerEid?: number } +): { 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; + + // 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 refs: Record = {}; + + // 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. 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; + eidMap.set(eid.source, copyOf(eid)); + const ref = refFor(eid, newObj?.refs); + if (ref) { + refs[eid.source] = ref; + } else { + delete refs[eid.source]; + } + }); + + const mergedEids: CachedEid[] = []; + const staleUid2s: StaleUid2[] = []; + eidMap.forEach((eid) => { + mergedEids.push(eid); + }); + + const merged: ResolvedCache = { + ortb2: { + user: { + data: newObj?.ortb2?.user?.data || oldObj?.ortb2?.user?.data || [], + 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 }; +} + +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 }; 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));