From 467cc458f2723be53ef7750fc78e58bcc5106ddd Mon Sep 17 00:00:00 2001 From: mosherBT Date: Wed, 26 Aug 2026 15:36:51 -0300 Subject: [PATCH 1/4] uid2: apply refresh outcomes to the targeting cache PRODUCT-3938 --- lib/addons/uid2-refresh.md | 12 ++++- lib/addons/uid2-refresh.test.ts | 94 ++++++++++++++++++++++++++++++++- lib/addons/uid2-refresh.ts | 49 ++++++++++++++++- 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md index 84e0a231..ff251c88 100644 --- a/lib/addons/uid2-refresh.md +++ b/lib/addons/uid2-refresh.md @@ -24,4 +24,14 @@ Returns one of: A response that cannot be decoded or decrypted throws; error policy stays with the caller. -Cache updates and the stale-token refresh loop ship separately. +## applyUid2Refresh + +```js +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` or `error`, the EID is removed. Either write is followed by the `optable-targeting:change` event so consumers (e.g. a pubProvidedId merge) can re-read the cache. 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 26f23916..0976b3c2 100644 --- a/lib/addons/uid2-refresh.test.ts +++ b/lib/addons/uid2-refresh.test.ts @@ -2,7 +2,11 @@ import { webcrypto } from "node:crypto"; import { TextDecoder } from "node:util"; import { http, HttpResponse } from "msw"; import { server } from "../test/server"; -import { refreshUid2Token, UID2_REFRESH_ENDPOINT, Uid2RefData } from "./uid2-refresh"; +import { refreshUid2Token, applyUid2Refresh, UID2_REFRESH_ENDPOINT, Uid2RefData } from "./uid2-refresh"; +import { DCN_DEFAULTS } from "../config"; +import type { ResolvedConfig } from "../config"; +import { LocalStorage } from "../core/storage"; +import type { TargetingResponse } from "../edge/targeting"; Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true }); (globalThis as { TextDecoder?: unknown }).TextDecoder = TextDecoder; @@ -113,3 +117,91 @@ describe("refreshUid2Token", () => { await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).rejects.toBeDefined(); }); }); + +describe("applyUid2Refresh", () => { + const config = { host: "uid2-apply-host.com", site: "site", consent: DCN_DEFAULTS.consent } as ResolvedConfig; + + const OLD_REF: Uid2RefData = { + advertising_token: "OLD_TOKEN", + refresh_token: "OLD_REFRESH_TOKEN", + refresh_response_key: "OLD_RESPONSE_KEY", + refresh_from: 1, + refresh_expires: 2, + identity_expires: 3, + }; + + function seedCache(): void { + const targeting = { + ortb2: { + user: { + data: [], + eids: [ + { source: "uidapi.com", uids: [{ atype: 3, id: "OLD_TOKEN" }], _ref: OLD_REF }, + { source: "other.com", uids: [{ id: "KEEP" }] }, + ], + }, + }, + } as unknown as TargetingResponse; + new LocalStorage(config).setTargeting(targeting); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function cachedEids(): any[] { + return (new LocalStorage(config).getTargeting()?.ortb2?.user?.eids as any[]) ?? []; + } + + const events: Event[] = []; + const listener = (e: Event) => events.push(e); + + beforeEach(() => { + localStorage.clear(); + events.length = 0; + window.addEventListener("optable-targeting:change", listener); + }); + + afterEach(() => { + window.removeEventListener("optable-targeting:change", listener); + }); + + it("rewrites the EID's uids and _ref 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(eids[1].source).toBe("other.com"); + expect(events).toHaveLength(1); + }); + + it("removes the EID on optout and sends the change event", () => { + seedCache(); + applyUid2Refresh(config, "uidapi.com", { status: "optout" }); + + const eids = cachedEids(); + expect(eids).toHaveLength(1); + expect(eids[0].source).toBe("other.com"); + expect(events).toHaveLength(1); + }); + + it("removes the EID on error", () => { + seedCache(); + applyUid2Refresh(config, "uidapi.com", { status: "error", reason: "expired_token" }); + + expect(cachedEids().map((e) => e.source)).toEqual(["other.com"]); + }); + + it("does nothing when the source is not in the cache", () => { + seedCache(); + applyUid2Refresh(config, "missing.com", { status: "optout" }); + + expect(cachedEids()).toHaveLength(2); + expect(events).toHaveLength(0); + }); + + it("does nothing when the cache is empty", () => { + expect(() => applyUid2Refresh(config, "uidapi.com", { status: "optout" })).not.toThrow(); + expect(events).toHaveLength(0); + }); +}); diff --git a/lib/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts index e9a7d3c8..cad020af 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -1,3 +1,9 @@ +import type { EID } from "iab-openrtb/v26"; +import { AgentType } from "iab-adcom"; +import type { ResolvedConfig } from "../config"; +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 = { @@ -14,6 +20,8 @@ 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"; function isUid2RefData(body: unknown): body is Uid2RefData { @@ -86,5 +94,42 @@ async function refreshUid2Token( return { status: "success", body: parsed.body }; } -export { refreshUid2Token, UID2_REFRESH_ENDPOINT }; -export type { Uid2RefData, Uid2RefreshResult }; +/** + * Applies a refresh outcome to the targeting cache: success rewrites the + * matching EID's uids and _ref in place, any other outcome removes the EID. + * Sends the targeting change event after each cache write so consumers + * (e.g. a pubProvidedId merge) can re-read the cache. + */ +function applyUid2Refresh(config: ResolvedConfig, source: string, result: Uid2RefreshResult): void { + const ls = new LocalStorage(config); + const cached = ls.getTargeting(); + const eids: RefreshableEID[] | undefined = cached?.ortb2?.user?.eids; + if (!cached || !eids) { + return; + } + + const idx = eids.findIndex((e) => e.source === source); + if (idx === -1) { + return; + } + + 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, + }; + } else { + eids.splice(idx, 1); + } + + ls.setTargeting(cached); + sendTargetingUpdateEvent(config, cached); +} + +export { refreshUid2Token, applyUid2Refresh, UID2_REFRESH_ENDPOINT }; +export type { Uid2RefData, Uid2RefreshResult, RefreshableEID }; From a7b4f05a322f0d9a41ecdeeac366ca7fc74c7527 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Wed, 26 Aug 2026 16:01:52 -0300 Subject: [PATCH 2/4] comment --- lib/addons/uid2-refresh.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts index cad020af..d0c0e9d2 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -104,6 +104,7 @@ function applyUid2Refresh(config: ResolvedConfig, source: string, result: Uid2Re const ls = new LocalStorage(config); const cached = ls.getTargeting(); const eids: RefreshableEID[] | undefined = cached?.ortb2?.user?.eids; + // If cache does not exist don't try to set. if (!cached || !eids) { return; } From ef3aa5b8d5ac3ed580714dbb958be93d74d4be5e Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 27 Aug 2026 17:06:18 -0300 Subject: [PATCH 3/4] comments --- lib/addons/uid2-refresh.md | 2 +- lib/addons/uid2-refresh.test.ts | 45 +++++++++++++++++++++-- lib/addons/uid2-refresh.ts | 64 ++++++++++++++++++++------------- lib/core/storage.ts | 30 ++++++++++++++++ 4 files changed, 112 insertions(+), 29 deletions(-) diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md index ff251c88..3a3c4cf8 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` or `error`, the EID is removed. Either write is followed by the `optable-targeting:change` event so consumers (e.g. a pubProvidedId merge) can re-read the cache. 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 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. The stale-token refresh loop ships separately. diff --git a/lib/addons/uid2-refresh.test.ts b/lib/addons/uid2-refresh.test.ts index 0976b3c2..6969607f 100644 --- a/lib/addons/uid2-refresh.test.ts +++ b/lib/addons/uid2-refresh.test.ts @@ -119,7 +119,12 @@ describe("refreshUid2Token", () => { }); describe("applyUid2Refresh", () => { - const config = { host: "uid2-apply-host.com", site: "site", consent: DCN_DEFAULTS.consent } as ResolvedConfig; + const config = { + host: "uid2-apply-host.com", + site: "site", + consent: DCN_DEFAULTS.consent, + optableCacheTargeting: "OPTABLE_RESOLVED", + } as ResolvedConfig; const OLD_REF: Uid2RefData = { advertising_token: "OLD_TOKEN", @@ -185,13 +190,47 @@ describe("applyUid2Refresh", () => { expect(events).toHaveLength(1); }); - it("removes the EID on error", () => { + it("updates each cache copy independently, preserving a merged public copy", () => { seedCache(); - applyUid2Refresh(config, "uidapi.com", { status: "error", reason: "expired_token" }); + // A wrapper's merged public copy carries an EID the raw private copy does + // not have; the refresh must update the UID2 entry in both copies without + // one representation overwriting the other. + const merged = JSON.parse(localStorage.getItem("OPTABLE_RESOLVED") as string); + merged.ortb2.user.eids.push({ source: "carryover.com", uids: [{ id: "CARRIED" }] }); + localStorage.setItem("OPTABLE_RESOLVED", JSON.stringify(merged)); + + applyUid2Refresh(config, "uidapi.com", { status: "success", body: BODY }); + + const privateEids = cachedEids(); + expect(privateEids.map((e) => e.source)).toEqual(["uidapi.com", "other.com"]); + expect(privateEids[0].uids).toEqual([{ atype: 3, id: BODY.advertising_token }]); + + 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); + }); + + it.each(["invalid_token", "expired_token"])("removes the EID on a definitive %s rejection", (reason) => { + seedCache(); + applyUid2Refresh(config, "uidapi.com", { status: "error", reason }); expect(cachedEids().map((e) => e.source)).toEqual(["other.com"]); }); + it.each(["HTTP 500", "client_error", "unauthorized", "malformed response body"])( + "leaves the cache untouched on a transient %s error", + (reason) => { + seedCache(); + const before = localStorage.getItem("OPTABLE_RESOLVED"); + applyUid2Refresh(config, "uidapi.com", { status: "error", reason }); + + expect(localStorage.getItem("OPTABLE_RESOLVED")).toBe(before); + expect(cachedEids().map((e) => e.source)).toEqual(["uidapi.com", "other.com"]); + expect(events).toHaveLength(0); + } + ); + 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 d0c0e9d2..ee802703 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -94,42 +94,56 @@ async function refreshUid2Token( return { status: "success", body: parsed.body }; } +// Operator rejections that mean the cached identity is definitively dead. +const EVICTION_REASONS = new Set(["invalid_token", "expired_token"]); + /** * Applies a refresh outcome to the targeting cache: success rewrites the - * matching EID's uids and _ref in place, any other outcome removes the EID. + * matching EID's uids and _ref in place, opt-out and definitive rejections + * remove the EID, and any other error leaves the cache untouched — the cached + * token stays valid until identity_expires and the next page load retries. * Sends the targeting change event after each cache write so consumers * (e.g. a pubProvidedId merge) can re-read the cache. */ function applyUid2Refresh(config: ResolvedConfig, source: string, result: Uid2RefreshResult): void { - const ls = new LocalStorage(config); - const cached = ls.getTargeting(); - const eids: RefreshableEID[] | undefined = cached?.ortb2?.user?.eids; - // If cache does not exist don't try to set. - if (!cached || !eids) { + if (result.status === "error" && !EVICTION_REASONS.has(result.reason)) { return; } - const idx = eids.findIndex((e) => e.source === source); - if (idx === -1) { - return; - } + // The private and public copies of the cache can hold different + // representations (wrappers merge into the public one), so each copy is + // updated in place rather than read from one key and written over the other. + const updated = new LocalStorage(config).updateTargeting((cached) => { + const eids: RefreshableEID[] | undefined = cached?.ortb2?.user?.eids; + // If cache does not exist don't try to set. + if (!eids) { + return false; + } - 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, - }; - } else { - eids.splice(idx, 1); - } + const idx = eids.findIndex((e) => e.source === source); + if (idx === -1) { + return false; + } - ls.setTargeting(cached); - sendTargetingUpdateEvent(config, cached); + 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, + }; + } else { + eids.splice(idx, 1); + } + return true; + }); + + if (updated) { + sendTargetingUpdateEvent(config, updated); + } } export { refreshUid2Token, applyUid2Refresh, UID2_REFRESH_ENDPOINT }; diff --git a/lib/core/storage.ts b/lib/core/storage.ts index f1b236ef..77f18d4c 100644 --- a/lib/core/storage.ts +++ b/lib/core/storage.ts @@ -69,6 +69,36 @@ class LocalStorage { this.setPairIDs(targeting); } + // Applies an in-place update to every stored copy of the targeting response + // independently. The private and public (optableCacheTargeting) copies can + // hold different representations — wrappers merge EIDs into the public key — + // so each copy is read, updated and written back on its own rather than one + // overwriting the others. Returns the last updated copy (the public one when + // both change), or null when no copy was updated. + updateTargeting(update: (targeting: TargetingResponse) => boolean): TargetingResponse | null { + let updated: TargetingResponse | null = null; + const keys = [...new Set([...this.targetingKeys.read, ...this.targetingKeys.write])].filter(Boolean); + + for (const key of keys) { + const raw = this.storage.getItem(key); + if (!raw) { + continue; + } + + try { + const targeting: TargetingResponse = JSON.parse(raw); + if (update(targeting)) { + this.storage.setItem(key, JSON.stringify(targeting)); + updated = targeting; + } + } catch { + // Leave an unparseable copy untouched. + } + } + + return updated; + } + getSite(): SiteResponse | null { const raw = this.readStorageKeys(this.siteKeys); return raw ? JSON.parse(raw) : null; From dff71a28162f7f07831be36f4e0613127afe2036 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Tue, 1 Sep 2026 10:22:09 -0300 Subject: [PATCH 4/4] comments --- lib/addons/uid2-refresh.test.ts | 5 ++--- lib/addons/uid2-refresh.ts | 11 +++-------- lib/core/storage.ts | 22 +++++++++++----------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/lib/addons/uid2-refresh.test.ts b/lib/addons/uid2-refresh.test.ts index 6969607f..5a3438ae 100644 --- a/lib/addons/uid2-refresh.test.ts +++ b/lib/addons/uid2-refresh.test.ts @@ -192,9 +192,7 @@ describe("applyUid2Refresh", () => { it("updates each cache copy independently, preserving a merged public copy", () => { seedCache(); - // A wrapper's merged public copy carries an EID the raw private copy does - // not have; the refresh must update the UID2 entry in both copies without - // one representation overwriting the other. + // The merged public copy carries an EID the private copy does not. const merged = JSON.parse(localStorage.getItem("OPTABLE_RESOLVED") as string); merged.ortb2.user.eids.push({ source: "carryover.com", uids: [{ id: "CARRIED" }] }); localStorage.setItem("OPTABLE_RESOLVED", JSON.stringify(merged)); @@ -216,6 +214,7 @@ describe("applyUid2Refresh", () => { applyUid2Refresh(config, "uidapi.com", { status: "error", reason }); expect(cachedEids().map((e) => e.source)).toEqual(["other.com"]); + expect(events).toHaveLength(1); }); it.each(["HTTP 500", "client_error", "unauthorized", "malformed response body"])( diff --git a/lib/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts index ee802703..7bdb24b4 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -99,20 +99,15 @@ const EVICTION_REASONS = new Set(["invalid_token", "expired_token"]); /** * Applies a refresh outcome to the targeting cache: success rewrites the - * matching EID's uids and _ref in place, opt-out and definitive rejections - * remove the EID, and any other error leaves the cache untouched — the cached - * token stays valid until identity_expires and the next page load retries. - * Sends the targeting change event after each cache write so consumers - * (e.g. a pubProvidedId merge) can re-read the cache. + * 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. */ function applyUid2Refresh(config: ResolvedConfig, source: string, result: Uid2RefreshResult): void { if (result.status === "error" && !EVICTION_REASONS.has(result.reason)) { return; } - // The private and public copies of the cache can hold different - // representations (wrappers merge into the public one), so each copy is - // updated in place rather than read from one key and written over the other. const updated = new LocalStorage(config).updateTargeting((cached) => { const eids: RefreshableEID[] | undefined = cached?.ortb2?.user?.eids; // If cache does not exist don't try to set. diff --git a/lib/core/storage.ts b/lib/core/storage.ts index 77f18d4c..d91a2e7b 100644 --- a/lib/core/storage.ts +++ b/lib/core/storage.ts @@ -69,12 +69,9 @@ class LocalStorage { this.setPairIDs(targeting); } - // Applies an in-place update to every stored copy of the targeting response - // independently. The private and public (optableCacheTargeting) copies can - // hold different representations — wrappers merge EIDs into the public key — - // so each copy is read, updated and written back on its own rather than one - // overwriting the others. Returns the last updated copy (the public one when - // both change), or null when no copy was updated. + // Updates every stored copy of the targeting response independently: the + // private and public copies can hold different representations, so each is + // read, updated and written back on its own. Returns the last updated copy. updateTargeting(update: (targeting: TargetingResponse) => boolean): TargetingResponse | null { let updated: TargetingResponse | null = null; const keys = [...new Set([...this.targetingKeys.read, ...this.targetingKeys.write])].filter(Boolean); @@ -85,14 +82,17 @@ class LocalStorage { continue; } + let targeting: TargetingResponse; try { - const targeting: TargetingResponse = JSON.parse(raw); - if (update(targeting)) { - this.storage.setItem(key, JSON.stringify(targeting)); - updated = targeting; - } + targeting = JSON.parse(raw); } catch { // Leave an unparseable copy untouched. + continue; + } + + if (update(targeting)) { + this.storage.setItem(key, JSON.stringify(targeting)); + updated = targeting; } }