From 1709272965026700b9ab3e0adb90386531f1d100 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Mon, 2 Nov 2026 11:58:39 -0500 Subject: [PATCH 1/5] uid2-refresh: refresh a stale cached UID2 on pages that never merge a response --- lib/addons/uid2-refresh.md | 12 +++++ lib/addons/uid2-refresh.test.ts | 86 ++++++++++++++++++++++++++++++++- lib/addons/uid2-refresh.ts | 32 ++++++++++-- lib/core/eid-cache.ts | 1 + 4 files changed, 127 insertions(+), 4 deletions(-) diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md index 1667fe0c..48021579 100644 --- a/lib/addons/uid2-refresh.md +++ b/lib/addons/uid2-refresh.md @@ -44,3 +44,15 @@ await refreshStaleUid2s(config, staleUid2s); ``` The ready-made loop over `mergeCache`'s `staleUid2s`: refreshes each source's token against the operator and applies the outcome to the cache. Entries run sequentially, since every apply is a read-modify-write of the same cache copies. Entries without valid refresh material are skipped, failures are logged through the `optableDebug`-gated `debugLog`, and a failed entry does not stop the rest — nothing throws into the host page. + +## refreshStaleUid2sFromCache + +```js +import { refreshStaleUid2sFromCache } from "@optable/web-sdk/lib/dist/addons/uid2-refresh"; + +await refreshStaleUid2sFromCache(sdk.dcn); +``` + +Reads the targeting cache, and if the UID2 it holds is past its `refresh_from`, refreshes it through `refreshStaleUid2s`. Use this on a page that served targeting from cache: only `mergeCache` produces a `staleUid2s` list, and it runs when a response is merged, so a page that never calls the edge has nothing to pass to `refreshStaleUid2s`. That is also the page where the cached token has had time to age. + +A missing cache, an unreadable one, a cache without refresh material, and a token not yet due are all no-ops that make no request. diff --git a/lib/addons/uid2-refresh.test.ts b/lib/addons/uid2-refresh.test.ts index f45dea4b..2be0d9e4 100644 --- a/lib/addons/uid2-refresh.test.ts +++ b/lib/addons/uid2-refresh.test.ts @@ -2,7 +2,13 @@ import { webcrypto } from "node:crypto"; import { TextDecoder } from "node:util"; import { http, HttpResponse } from "msw"; import { server } from "../test/server"; -import { refreshUid2Token, applyUid2Refresh, refreshStaleUid2s, UID2_REFRESH_ENDPOINT } from "./uid2-refresh"; +import { + refreshUid2Token, + applyUid2Refresh, + refreshStaleUid2s, + refreshStaleUid2sFromCache, + UID2_REFRESH_ENDPOINT, +} from "./uid2-refresh"; import type { StaleUid2, Uid2RefData } from "../core/eid-cache"; import { DCN_DEFAULTS } from "../config"; import type { ResolvedConfig } from "../config"; @@ -381,3 +387,81 @@ describe("refreshStaleUid2s", () => { expect(events).toHaveLength(0); }); }); + +describe("refreshStaleUid2sFromCache", () => { + const config = { + host: "uid2-from-cache-host.com", + site: "site", + consent: DCN_DEFAULTS.consent, + optableCacheTargeting: "OPTABLE_RESOLVED", + } as ResolvedConfig; + + const REF: Uid2RefData = { + advertising_token: "OLD_TOKEN", + refresh_token: "REFRESH_TOKEN", + refresh_response_key: KEY_B64, + refresh_from: 1, + refresh_expires: 2734462312780, + identity_expires: 1734459312780, + }; + + function seed(refs: Record): void { + const targeting = { + ortb2: { user: { data: [], eids: [{ source: "uidapi.com", uids: [{ atype: 3, id: "OLD_TOKEN" }] }] } }, + refs, + } 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[]) ?? []; + } + + let calls = 0; + + beforeEach(async () => { + localStorage.clear(); + calls = 0; + const encrypted = await encryptResponse({ status: "success", body: BODY }); + server.use( + http.post(UID2_REFRESH_ENDPOINT, () => { + calls++; + return new HttpResponse(encrypted, { status: 200 }); + }) + ); + }); + + it("refreshes a token the cache holds past its refresh point", async () => { + seed({ "uidapi.com": REF }); + + await refreshStaleUid2sFromCache(config); + + expect(calls).toBe(1); + expect(cachedEids()[0].uids).toEqual([{ atype: 3, id: BODY.advertising_token }]); + expect(new LocalStorage(config).getTargeting()?.refs).toEqual({ "uidapi.com": BODY }); + }); + + it("leaves a token that is not due for refresh yet", async () => { + seed({ "uidapi.com": { ...REF, refresh_from: Date.now() + 60_000 } }); + + await refreshStaleUid2sFromCache(config); + + expect(calls).toBe(0); + expect(cachedEids()[0].uids).toEqual([{ atype: 3, id: "OLD_TOKEN" }]); + }); + + it("does nothing when the cache holds no refresh material", async () => { + seed({}); + + await refreshStaleUid2sFromCache(config); + + expect(calls).toBe(0); + expect(cachedEids()[0].uids).toEqual([{ atype: 3, id: "OLD_TOKEN" }]); + }); + + it("does nothing when there is no cache", async () => { + await expect(refreshStaleUid2sFromCache(config)).resolves.toBeUndefined(); + expect(calls).toBe(0); + }); +}); diff --git a/lib/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts index c78898cb..880e55c9 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -1,7 +1,7 @@ import { AgentType } from "iab-adcom"; import type { ResolvedConfig } from "../config"; -import { isUid2RefData } from "../core/eid-cache"; -import type { StaleUid2, Uid2RefData } from "../core/eid-cache"; +import { getRefData, isUid2RefData, isUid2Stale, UID2_SOURCE } from "../core/eid-cache"; +import type { ResolvedCache, StaleUid2, Uid2RefData } from "../core/eid-cache"; import { LocalStorage } from "../core/storage"; import { sendTargetingUpdateEvent } from "../core/events/cache-refresh"; import { debugLog } from "../core/log"; @@ -147,5 +147,31 @@ async function refreshStaleUid2s(config: ResolvedConfig, stale: StaleUid2[]): Pr } } -export { refreshUid2Token, applyUid2Refresh, refreshStaleUid2s, UID2_REFRESH_ENDPOINT }; +/** + * Refreshes the cached UID2 if it is past its refresh point. The entry point + * for a page that served targeting from cache: nothing merges a response + * there, so there is no mergeCache result to take staleUid2s from. + */ +async function refreshStaleUid2sFromCache(config: ResolvedConfig): Promise { + let cached: ResolvedCache | null = null; + try { + cached = new LocalStorage(config).getTargeting() as ResolvedCache | null; + } catch (e) { + debugLog("error", "UID2: could not read the targeting cache", e); + return; + } + + if (!isUid2Stale(cached)) { + return; + } + + const ref = getRefData(cached, UID2_SOURCE); + if (!ref) { + return; + } + + await refreshStaleUid2s(config, [{ source: UID2_SOURCE, ref }]); +} + +export { refreshUid2Token, applyUid2Refresh, refreshStaleUid2s, refreshStaleUid2sFromCache, UID2_REFRESH_ENDPOINT }; export type { Uid2RefData, Uid2RefreshResult }; diff --git a/lib/core/eid-cache.ts b/lib/core/eid-cache.ts index 7a1e9f13..32ee25bc 100644 --- a/lib/core/eid-cache.ts +++ b/lib/core/eid-cache.ts @@ -236,4 +236,5 @@ function stripRefPointer(uid: CachedUid): CachedUid { return copy; } +export { UID2_SOURCE }; export type { CachedEid, ResolvedCache, StaleUid2, Uid2RefData }; From 64c245adae8767eedbdc4ac144f2c95b47253e78 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Mon, 2 Nov 2026 13:35:11 -0500 Subject: [PATCH 2/5] uid2-refresh: detect a stale cached UID2 in every stored copy, not just the private one --- lib/addons/uid2-refresh.md | 6 ++++-- lib/addons/uid2-refresh.test.ts | 37 +++++++++++++++++++++++++++++++++ lib/addons/uid2-refresh.ts | 15 ++++++------- lib/core/eid-cache.md | 3 ++- lib/core/storage.ts | 30 ++++++++++++++++++++++++-- 5 files changed, 79 insertions(+), 12 deletions(-) diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md index 48021579..f3aa6a79 100644 --- a/lib/addons/uid2-refresh.md +++ b/lib/addons/uid2-refresh.md @@ -53,6 +53,8 @@ import { refreshStaleUid2sFromCache } from "@optable/web-sdk/lib/dist/addons/uid await refreshStaleUid2sFromCache(sdk.dcn); ``` -Reads the targeting cache, and if the UID2 it holds is past its `refresh_from`, refreshes it through `refreshStaleUid2s`. Use this on a page that served targeting from cache: only `mergeCache` produces a `staleUid2s` list, and it runs when a response is merged, so a page that never calls the edge has nothing to pass to `refreshStaleUid2s`. That is also the page where the cached token has had time to age. +Reads every stored copy of the targeting cache, and if any holds a UID2 past its `refresh_from`, refreshes it through `refreshStaleUid2s`. Use this on a page that served targeting from cache: only `mergeCache` produces a `staleUid2s` list, and it runs when a response is merged, so a page that never calls the edge has nothing to pass to `refreshStaleUid2s`. That is also the page where the cached token has had time to age. -A missing cache, an unreadable one, a cache without refresh material, and a token not yet due are all no-ops that make no request. +Both copies are checked, the private `OPTABLE_TARGETING_` one and the public `optableCacheTargeting` one, because they can differ: `identifyAndTokenize` writes its merged cache only to its own `cacheKey` (`OPTABLE_RESOLVED` by default), which is also where prebid RTD and `pubProvidedId` read from. For a tokenize-resolved UID2 to be refreshable, that key and `optableCacheTargeting` have to name the same thing. `applyUid2Refresh` then writes the outcome back to every copy. + +A missing cache, an unparseable copy, a cache without refresh material, and a token not yet due are all no-ops that make no request. diff --git a/lib/addons/uid2-refresh.test.ts b/lib/addons/uid2-refresh.test.ts index 2be0d9e4..be34f28c 100644 --- a/lib/addons/uid2-refresh.test.ts +++ b/lib/addons/uid2-refresh.test.ts @@ -464,4 +464,41 @@ describe("refreshStaleUid2sFromCache", () => { await expect(refreshStaleUid2sFromCache(config)).resolves.toBeUndefined(); expect(calls).toBe(0); }); + + it("refreshes a token held only by the public copy", async () => { + // What identifyAndTokenize leaves behind: it writes the merged cache to + // OPTABLE_RESOLVED only, and that copy is what prebid serves. + localStorage.setItem( + "OPTABLE_RESOLVED", + JSON.stringify({ + ortb2: { user: { data: [], eids: [{ source: "uidapi.com", uids: [{ atype: 3, id: "OLD_TOKEN" }] }] } }, + refs: { "uidapi.com": REF }, + }) + ); + + await refreshStaleUid2sFromCache(config); + + expect(calls).toBe(1); + const stored = JSON.parse(localStorage.getItem("OPTABLE_RESOLVED") as string); + expect(stored.ortb2.user.eids[0].uids).toEqual([{ atype: 3, id: BODY.advertising_token }]); + expect(stored.refs).toEqual({ "uidapi.com": BODY }); + }); + + it("skips a copy that cannot be parsed", async () => { + localStorage.setItem("OPTABLE_RESOLVED", "not json"); + + await expect(refreshStaleUid2sFromCache(config)).resolves.toBeUndefined(); + expect(calls).toBe(0); + }); + + it("does nothing when the cache cannot be read", async () => { + const spy = jest.spyOn(LocalStorage.prototype, "getTargetingCopies").mockImplementation(() => { + throw new Error("storage unavailable"); + }); + + await expect(refreshStaleUid2sFromCache(config)).resolves.toBeUndefined(); + expect(calls).toBe(0); + + spy.mockRestore(); + }); }); diff --git a/lib/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts index 880e55c9..7612f154 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -153,19 +153,20 @@ async function refreshStaleUid2s(config: ResolvedConfig, stale: StaleUid2[]): Pr * there, so there is no mergeCache result to take staleUid2s from. */ async function refreshStaleUid2sFromCache(config: ResolvedConfig): Promise { - let cached: ResolvedCache | null = null; + let ref: Uid2RefData | null = null; try { - cached = new LocalStorage(config).getTargeting() as ResolvedCache | null; + // Every copy, not just the private one: a UID2 resolved by + // identifyAndTokenize lands only in the public copy, which is the one + // prebid serves. applyUid2Refresh writes the outcome back to all of them. + ref = + (new LocalStorage(config).getTargetingCopies() as ResolvedCache[]) + .map((cached) => (isUid2Stale(cached) ? getRefData(cached, UID2_SOURCE) : null)) + .find((found) => found !== null) ?? null; } catch (e) { debugLog("error", "UID2: could not read the targeting cache", e); return; } - if (!isUid2Stale(cached)) { - return; - } - - const ref = getRefData(cached, UID2_SOURCE); if (!ref) { return; } diff --git a/lib/core/eid-cache.md b/lib/core/eid-cache.md index 2460340e..35661919 100644 --- a/lib/core/eid-cache.md +++ b/lib/core/eid-cache.md @@ -44,7 +44,7 @@ Listing a publisher's own domains is what lets a targeting call resolved with `s ## UID2 refresh material -`replaceCache` validates the refresh material a response points at and keys it by EID `source`, dropping the `ext.optable.ref` pointers. `mergeCache` then carries those entries across merges and returns sources past their `refresh_from` as `staleUid2s` (`{ source, ref }` pairs); pass them to the [UID2 refresh addon](../addons/uid2-refresh.md)'s `refreshStaleUid2s(config, staleUid2s)` to refresh each and apply the outcome to the cache. +`replaceCache` validates the refresh material a response points at and keys it by EID `source`, dropping the `ext.optable.ref` pointers. `mergeCache` then carries those entries across merges and returns sources past their `refresh_from` as `staleUid2s` (`{ source, ref }` pairs); pass them to the [UID2 refresh addon](../addons/uid2-refresh.md)'s `refreshStaleUid2s(config, staleUid2s)` to refresh each and apply the outcome to the cache. On a page that merges no response, there is no such list; the addon's `refreshStaleUid2sFromCache(config)` reads the stored cache instead and refreshes what it finds. 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. @@ -60,3 +60,4 @@ Caches written by earlier wrappers carried refresh material as `_ref` on the EID | `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. | +| `UID2_SOURCE` | `"uidapi.com"` | The EID source UID2 refresh material is keyed under. | diff --git a/lib/core/storage.ts b/lib/core/storage.ts index 64d75a47..4d083858 100644 --- a/lib/core/storage.ts +++ b/lib/core/storage.ts @@ -98,14 +98,40 @@ class LocalStorage { this.setPairIDs(targeting); } + // Every key holding a copy of the targeting response: the private one and + // the public one, which other consumers read and write directly. + private targetingCopyKeys(): string[] { + return [...new Set([...this.targetingKeys.read, ...this.targetingKeys.write])].filter(Boolean); + } + + // Every stored copy that parses, in key order. The copies can hold different + // representations, so a caller inspecting the cache has to see all of them. + getTargetingCopies(): TargetingResponse[] { + const copies: TargetingResponse[] = []; + + for (const key of this.targetingCopyKeys()) { + const raw = this.storage.getItem(key); + if (!raw) { + continue; + } + + try { + copies.push(JSON.parse(raw)); + } catch { + // Skip an unparseable copy. + } + } + + return copies; + } + // 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); - for (const key of keys) { + for (const key of this.targetingCopyKeys()) { const raw = this.storage.getItem(key); if (!raw) { continue; From 2ce3dfd1b7ff5cf285e6156eaf86d8285a894d5b Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Mon, 2 Nov 2026 13:56:52 -0500 Subject: [PATCH 3/5] uid2-refresh: trim the comments to what the code does not already say --- lib/addons/uid2-refresh.test.ts | 3 +-- lib/addons/uid2-refresh.ts | 10 ++++------ lib/core/storage.ts | 7 +++---- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/lib/addons/uid2-refresh.test.ts b/lib/addons/uid2-refresh.test.ts index be34f28c..39174ef0 100644 --- a/lib/addons/uid2-refresh.test.ts +++ b/lib/addons/uid2-refresh.test.ts @@ -466,8 +466,7 @@ describe("refreshStaleUid2sFromCache", () => { }); it("refreshes a token held only by the public copy", async () => { - // What identifyAndTokenize leaves behind: it writes the merged cache to - // OPTABLE_RESOLVED only, and that copy is what prebid serves. + // identifyAndTokenize writes the merged cache to OPTABLE_RESOLVED only. localStorage.setItem( "OPTABLE_RESOLVED", JSON.stringify({ diff --git a/lib/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts index 7612f154..4c0a359f 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -148,16 +148,14 @@ async function refreshStaleUid2s(config: ResolvedConfig, stale: StaleUid2[]): Pr } /** - * Refreshes the cached UID2 if it is past its refresh point. The entry point - * for a page that served targeting from cache: nothing merges a response - * there, so there is no mergeCache result to take staleUid2s from. + * A page that serves targeting from cache merges no response, so it has no + * mergeCache result to take a staleUid2s list from. */ async function refreshStaleUid2sFromCache(config: ResolvedConfig): Promise { let ref: Uid2RefData | null = null; try { - // Every copy, not just the private one: a UID2 resolved by - // identifyAndTokenize lands only in the public copy, which is the one - // prebid serves. applyUid2Refresh writes the outcome back to all of them. + // identifyAndTokenize writes a resolved UID2 only to the public copy, + // which is the one prebid serves. ref = (new LocalStorage(config).getTargetingCopies() as ResolvedCache[]) .map((cached) => (isUid2Stale(cached) ? getRefData(cached, UID2_SOURCE) : null)) diff --git a/lib/core/storage.ts b/lib/core/storage.ts index 4d083858..51b009e9 100644 --- a/lib/core/storage.ts +++ b/lib/core/storage.ts @@ -98,14 +98,13 @@ class LocalStorage { this.setPairIDs(targeting); } - // Every key holding a copy of the targeting response: the private one and - // the public one, which other consumers read and write directly. + // The private key is in both lists, and the public key can be unset. private targetingCopyKeys(): string[] { return [...new Set([...this.targetingKeys.read, ...this.targetingKeys.write])].filter(Boolean); } - // Every stored copy that parses, in key order. The copies can hold different - // representations, so a caller inspecting the cache has to see all of them. + // Copies can hold different representations, so a caller that inspects the + // cache has to see all of them. getTargetingCopies(): TargetingResponse[] { const copies: TargetingResponse[] = []; From 9630e8cc05ab219009e855c02d38a5e9ff52e9fe Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Wed, 4 Nov 2026 14:17:42 -0500 Subject: [PATCH 4/5] uid2-refresh: apply each refreshed token only to the copies holding it --- lib/addons/uid2-refresh.md | 8 +++--- lib/addons/uid2-refresh.test.ts | 48 +++++++++++++++++++++++++++++++++ lib/addons/uid2-refresh.ts | 40 +++++++++++++++++---------- 3 files changed, 79 insertions(+), 17 deletions(-) diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md index f3aa6a79..5936ead8 100644 --- a/lib/addons/uid2-refresh.md +++ b/lib/addons/uid2-refresh.md @@ -29,10 +29,12 @@ A response that cannot be decoded or decrypted throws; error policy stays with t ```js import { applyUid2Refresh } from "@optable/web-sdk/lib/dist/addons/uid2-refresh"; -applyUid2Refresh(config, "uidapi.com", result); +applyUid2Refresh(config, "uidapi.com", result, refreshToken); ``` -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. +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. + +The optional fourth argument is the refresh token the outcome belongs to. Given one, a stored copy whose `refs` entry holds a different refresh token is left alone, so an eviction or a replacement never crosses from one identity to another. A copy carrying no refresh material for the source is still updated. Omit it and every copy holding the source is updated, which is what a caller applying a single known identity wants. 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. ## refreshStaleUid2s @@ -53,7 +55,7 @@ import { refreshStaleUid2sFromCache } from "@optable/web-sdk/lib/dist/addons/uid await refreshStaleUid2sFromCache(sdk.dcn); ``` -Reads every stored copy of the targeting cache, and if any holds a UID2 past its `refresh_from`, refreshes it through `refreshStaleUid2s`. Use this on a page that served targeting from cache: only `mergeCache` produces a `staleUid2s` list, and it runs when a response is merged, so a page that never calls the edge has nothing to pass to `refreshStaleUid2s`. That is also the page where the cached token has had time to age. +Reads every stored copy of the targeting cache and refreshes each distinct UID2 past its `refresh_from` through `refreshStaleUid2s`. Copies can hold different identities, so refreshes are keyed by refresh token: two copies holding the same token make one request, and two holding different ones are refreshed and written back separately. Use this on a page that served targeting from cache: only `mergeCache` produces a `staleUid2s` list, and it runs when a response is merged, so a page that never calls the edge has nothing to pass to `refreshStaleUid2s`. That is also the page where the cached token has had time to age. Both copies are checked, the private `OPTABLE_TARGETING_` one and the public `optableCacheTargeting` one, because they can differ: `identifyAndTokenize` writes its merged cache only to its own `cacheKey` (`OPTABLE_RESOLVED` by default), which is also where prebid RTD and `pubProvidedId` read from. For a tokenize-resolved UID2 to be refreshable, that key and `optableCacheTargeting` have to name the same thing. `applyUid2Refresh` then writes the outcome back to every copy. diff --git a/lib/addons/uid2-refresh.test.ts b/lib/addons/uid2-refresh.test.ts index 39174ef0..5bcd06f9 100644 --- a/lib/addons/uid2-refresh.test.ts +++ b/lib/addons/uid2-refresh.test.ts @@ -490,6 +490,54 @@ describe("refreshStaleUid2sFromCache", () => { expect(calls).toBe(0); }); + it("does not read the tokenize cache under the default optableCacheTargeting", async () => { + localStorage.setItem( + "OPTABLE_RESOLVED", + JSON.stringify({ + ortb2: { user: { data: [], eids: [{ source: "uidapi.com", uids: [{ atype: 3, id: "OLD_TOKEN" }] }] } }, + refs: { "uidapi.com": REF }, + }) + ); + + await refreshStaleUid2sFromCache({ ...config, optableCacheTargeting: DCN_DEFAULTS.optableCacheTargeting }); + + expect(calls).toBe(0); + }); + + it("applies each refresh only to the copy holding that token", async () => { + seed({ "uidapi.com": REF }); + const privateKey = Object.keys(localStorage).find((k) => k.startsWith("OPTABLE_TARGETING_")) as string; + + const other = { ...REF, advertising_token: "OTHER_TOKEN", refresh_token: "OTHER_REFRESH" }; + localStorage.setItem( + "OPTABLE_RESOLVED", + JSON.stringify({ + ortb2: { user: { data: [], eids: [{ source: "uidapi.com", uids: [{ atype: 3, id: "OTHER_TOKEN" }] }] } }, + refs: { "uidapi.com": other }, + }) + ); + + const encrypted = await encryptResponse({ status: "success", body: BODY }); + server.use( + http.post(UID2_REFRESH_ENDPOINT, async ({ request }) => { + calls++; + if ((await request.text()) === "OTHER_REFRESH") { + return HttpResponse.json({ status: "invalid_token" }, { status: 400 }); + } + return new HttpResponse(encrypted, { status: 200 }); + }) + ); + + await refreshStaleUid2sFromCache(config); + + expect(calls).toBe(2); + const priv = JSON.parse(localStorage.getItem(privateKey) as string); + expect(priv.ortb2.user.eids[0].uids).toEqual([{ atype: 3, id: BODY.advertising_token }]); + const pub = JSON.parse(localStorage.getItem("OPTABLE_RESOLVED") as string); + expect(pub.ortb2.user.eids).toEqual([]); + expect(pub.refs).toEqual({}); + }); + it("does nothing when the cache cannot be read", async () => { const spy = jest.spyOn(LocalStorage.prototype, "getTargetingCopies").mockImplementation(() => { throw new Error("storage unavailable"); diff --git a/lib/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts index 4c0a359f..46235678 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -79,7 +79,12 @@ const EVICTION_REASONS = new Set(["invalid_token", "expired_token"]); * 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 { +function applyUid2Refresh( + config: ResolvedConfig, + source: string, + result: Uid2RefreshResult, + refreshToken?: string +): void { if (result.status === "error" && !EVICTION_REASONS.has(result.reason)) { return; } @@ -91,6 +96,12 @@ function applyUid2Refresh(config: ResolvedConfig, source: string, result: Uid2Re return false; } + // A copy holding another identity's token is not this outcome's to touch. + const held = getRefData(cached as ResolvedCache, source); + if (refreshToken !== undefined && held && held.refresh_token !== refreshToken) { + return false; + } + const idx = eids.findIndex((e) => e.source === source); if (idx === -1) { return false; @@ -140,7 +151,7 @@ async function refreshStaleUid2s(config: ResolvedConfig, stale: StaleUid2[]): Pr debugLog("warn", `UID2: ${entry.source} refresh failed (${result.reason})`, result.message); } - applyUid2Refresh(config, entry.source, result); + applyUid2Refresh(config, entry.source, result, entry.ref.refresh_token); } catch (e) { debugLog("error", `UID2: ${entry?.source} refresh error`, e); } @@ -152,24 +163,25 @@ async function refreshStaleUid2s(config: ResolvedConfig, stale: StaleUid2[]): Pr * mergeCache result to take a staleUid2s list from. */ async function refreshStaleUid2sFromCache(config: ResolvedConfig): Promise { - let ref: Uid2RefData | null = null; + // identifyAndTokenize writes only to its own cacheKey, so its copy is read + // here only when optableCacheTargeting names that same key. + const stale = new Map(); try { - // identifyAndTokenize writes a resolved UID2 only to the public copy, - // which is the one prebid serves. - ref = - (new LocalStorage(config).getTargetingCopies() as ResolvedCache[]) - .map((cached) => (isUid2Stale(cached) ? getRefData(cached, UID2_SOURCE) : null)) - .find((found) => found !== null) ?? null; + for (const cached of new LocalStorage(config).getTargetingCopies() as ResolvedCache[]) { + const ref = isUid2Stale(cached) ? getRefData(cached, UID2_SOURCE) : null; + if (ref) { + stale.set(ref.refresh_token, ref); + } + } } catch (e) { debugLog("error", "UID2: could not read the targeting cache", e); return; } - if (!ref) { - return; - } - - await refreshStaleUid2s(config, [{ source: UID2_SOURCE, ref }]); + await refreshStaleUid2s( + config, + [...stale.values()].map((ref) => ({ source: UID2_SOURCE, ref })) + ); } export { refreshUid2Token, applyUid2Refresh, refreshStaleUid2s, refreshStaleUid2sFromCache, UID2_REFRESH_ENDPOINT }; From 7c3ab481517ba50cf6fc147f65afd7fd94c027b4 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Wed, 4 Nov 2026 14:27:12 -0500 Subject: [PATCH 5/5] uid2-refresh: correct the docs for the per-copy write --- lib/addons/uid2-refresh.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md index 5936ead8..8bce7cba 100644 --- a/lib/addons/uid2-refresh.md +++ b/lib/addons/uid2-refresh.md @@ -32,9 +32,9 @@ import { applyUid2Refresh } from "@optable/web-sdk/lib/dist/addons/uid2-refresh" applyUid2Refresh(config, "uidapi.com", result, refreshToken); ``` -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. +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 optional fourth argument is the refresh token the outcome belongs to. Given one, a stored copy whose `refs` entry holds a different refresh token is left alone, so an eviction or a replacement never crosses from one identity to another. A copy carrying no refresh material for the source is still updated. Omit it and every copy holding the source is updated, which is what a caller applying a single known identity wants. 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 optional `refreshToken` is the token the outcome belongs to. Given one, a stored copy whose `refs` entry holds a different refresh token is left alone, so an eviction or a replacement never crosses from one identity to another. A copy carrying no refresh material for the source is still updated. Omit it and every copy holding the source is updated, which is what a caller applying a single known identity wants. ## refreshStaleUid2s @@ -57,6 +57,6 @@ await refreshStaleUid2sFromCache(sdk.dcn); Reads every stored copy of the targeting cache and refreshes each distinct UID2 past its `refresh_from` through `refreshStaleUid2s`. Copies can hold different identities, so refreshes are keyed by refresh token: two copies holding the same token make one request, and two holding different ones are refreshed and written back separately. Use this on a page that served targeting from cache: only `mergeCache` produces a `staleUid2s` list, and it runs when a response is merged, so a page that never calls the edge has nothing to pass to `refreshStaleUid2s`. That is also the page where the cached token has had time to age. -Both copies are checked, the private `OPTABLE_TARGETING_` one and the public `optableCacheTargeting` one, because they can differ: `identifyAndTokenize` writes its merged cache only to its own `cacheKey` (`OPTABLE_RESOLVED` by default), which is also where prebid RTD and `pubProvidedId` read from. For a tokenize-resolved UID2 to be refreshable, that key and `optableCacheTargeting` have to name the same thing. `applyUid2Refresh` then writes the outcome back to every copy. +Both copies are checked, the private `OPTABLE_TARGETING_` one and the public `optableCacheTargeting` one, because they can differ: `identifyAndTokenize` writes its merged cache only to its own `cacheKey` (`OPTABLE_RESOLVED` by default), which is also where prebid RTD and `pubProvidedId` read from. For a tokenize-resolved UID2 to be refreshable, that key and `optableCacheTargeting` have to name the same thing; the config default (`optable-cache:targeting`) does not. `applyUid2Refresh` then writes each outcome back to the copies holding that refresh token. A missing cache, an unparseable copy, a cache without refresh material, and a token not yet due are all no-ops that make no request.