From 07d14e05db7463d59f1464d844998f1f52dea0bc Mon Sep 17 00:00:00 2001 From: mosherBT Date: Wed, 16 Sep 2026 12:17:43 -0400 Subject: [PATCH 1/2] uid2: add the refreshStaleUid2s stale-token loop --- lib/addons/uid2-refresh.md | 11 ++- lib/addons/uid2-refresh.test.ts | 126 +++++++++++++++++++++++++++++++- lib/addons/uid2-refresh.ts | 38 +++++++++- lib/core/eid-cache.md | 2 +- 4 files changed, 172 insertions(+), 5 deletions(-) diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md index e5c528ce..1667fe0c 100644 --- a/lib/addons/uid2-refresh.md +++ b/lib/addons/uid2-refresh.md @@ -34,4 +34,13 @@ 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. -The stale-token refresh loop ships separately. +## refreshStaleUid2s + +```js +import { refreshStaleUid2s } from "@optable/web-sdk/lib/dist/addons/uid2-refresh"; + +const { merged, staleUid2s } = mergeCache(replaceCache(response), cached); +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. diff --git a/lib/addons/uid2-refresh.test.ts b/lib/addons/uid2-refresh.test.ts index d2cb888f..2b604305 100644 --- a/lib/addons/uid2-refresh.test.ts +++ b/lib/addons/uid2-refresh.test.ts @@ -2,7 +2,8 @@ import { webcrypto } from "node:crypto"; import { TextDecoder } from "node:util"; import { http, HttpResponse } from "msw"; import { server } from "../test/server"; -import { refreshUid2Token, applyUid2Refresh, UID2_REFRESH_ENDPOINT, Uid2RefData } from "./uid2-refresh"; +import { refreshUid2Token, applyUid2Refresh, refreshStaleUid2s, UID2_REFRESH_ENDPOINT } from "./uid2-refresh"; +import type { StaleUid2, Uid2RefData } from "../core/eid-cache"; import { DCN_DEFAULTS } from "../config"; import type { ResolvedConfig } from "../config"; import { LocalStorage } from "../core/storage"; @@ -244,3 +245,126 @@ describe("applyUid2Refresh", () => { expect(events).toHaveLength(0); }); }); + +describe("refreshStaleUid2s", () => { + const config = { + host: "uid2-loop-host.com", + site: "site", + consent: DCN_DEFAULTS.consent, + optableCacheTargeting: "OPTABLE_RESOLVED", + } as ResolvedConfig; + + const STALE_REF: Uid2RefData = { + advertising_token: "OLD_TOKEN", + refresh_token: "REFRESH_TOKEN", + refresh_response_key: KEY_B64, + refresh_from: 1, + refresh_expires: 2734462312780, + identity_expires: 1734459312780, + }; + + const stale = (): StaleUid2[] => [{ source: "uidapi.com", ref: STALE_REF }]; + + function seedCache(): void { + const targeting = { + ortb2: { + user: { + data: [], + eids: [ + { source: "uidapi.com", uids: [{ atype: 3, id: "OLD_TOKEN" }] }, + { source: "other.com", uids: [{ id: "KEEP" }] }, + ], + }, + }, + refs: { "uidapi.com": STALE_REF }, + } 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("refreshes a stale token end to end and rewrites the refs sidecar", async () => { + seedCache(); + respondWith(await encryptResponse({ status: "success", body: BODY })); + + await refreshStaleUid2s(config, stale()); + + const eids = cachedEids(); + expect(eids[0].uids).toEqual([{ atype: 3, id: BODY.advertising_token }]); + expect(new LocalStorage(config).getTargeting()?.refs).toEqual({ "uidapi.com": BODY }); + expect(eids[1].source).toBe("other.com"); + expect(events).toHaveLength(1); + }); + + it("removes the EID and its refs entry on an opt-out", async () => { + seedCache(); + respondWith(await encryptResponse({ status: "optout" })); + + await refreshStaleUid2s(config, stale()); + + expect(cachedEids().map((e) => e.source)).toEqual(["other.com"]); + expect(new LocalStorage(config).getTargeting()?.refs).toEqual({}); + expect(events).toHaveLength(1); + }); + + it("skips entries without usable ref data", async () => { + seedCache(); + + await refreshStaleUid2s(config, [{ source: "uidapi.com" } as unknown as StaleUid2]); + + expect(cachedEids().map((e) => e.source)).toEqual(["uidapi.com", "other.com"]); + expect(events).toHaveLength(0); + }); + + it("does not throw on a network failure and leaves the cache untouched", async () => { + seedCache(); + server.use(http.post(UID2_REFRESH_ENDPOINT, () => HttpResponse.error())); + + await expect(refreshStaleUid2s(config, stale())).resolves.toBeUndefined(); + + expect(cachedEids().map((e) => e.source)).toEqual(["uidapi.com", "other.com"]); + expect(events).toHaveLength(0); + }); + + it("does not throw on an undecryptable response and leaves the cache untouched", async () => { + seedCache(); + respondWith(Buffer.from(webcrypto.getRandomValues(new Uint8Array(64))).toString("base64")); + + await expect(refreshStaleUid2s(config, stale())).resolves.toBeUndefined(); + + expect(cachedEids().map((e) => e.source)).toEqual(["uidapi.com", "other.com"]); + expect(events).toHaveLength(0); + }); + + it("keeps going after a failed entry", async () => { + seedCache(); + server.use(http.post(UID2_REFRESH_ENDPOINT, () => HttpResponse.error())); + + await expect( + refreshStaleUid2s(config, [{ source: "uidapi.com" } as unknown as StaleUid2, ...stale()]) + ).resolves.toBeUndefined(); + + expect(cachedEids().map((e) => e.source)).toEqual(["uidapi.com", "other.com"]); + }); + + it("is a no-op for an empty list", async () => { + await expect(refreshStaleUid2s(config, [])).resolves.toBeUndefined(); + expect(events).toHaveLength(0); + }); +}); diff --git a/lib/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts index b727d2db..5a1a278a 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -1,9 +1,10 @@ import { AgentType } from "iab-adcom"; import type { ResolvedConfig } from "../config"; import { isUid2RefData } from "../core/eid-cache"; -import type { Uid2RefData } from "../core/eid-cache"; +import type { StaleUid2, Uid2RefData } from "../core/eid-cache"; import { LocalStorage } from "../core/storage"; import { sendTargetingUpdateEvent } from "../core/events/cache-refresh"; +import { debugLog } from "../core/log"; type Uid2RefreshResult = | { status: "success"; body: Uid2RefData } @@ -112,5 +113,38 @@ function applyUid2Refresh(config: ResolvedConfig, source: string, result: Uid2Re } } -export { refreshUid2Token, applyUid2Refresh, UID2_REFRESH_ENDPOINT }; +/** + * Refreshes every stale UID2 returned by mergeCache against the operator and + * applies each outcome to the targeting cache. Never throws into the host page: + * a failed entry is logged and the rest still run. + */ +async function refreshStaleUid2s(config: ResolvedConfig, stale: StaleUid2[]): Promise { + if (stale.length) { + debugLog("info", `UID2: refreshing ${stale.length} stale token(s)`); + } + + // Sequential: each apply is a read-modify-write of the same cache copies. + for (const entry of stale) { + try { + if (!isUid2RefData(entry?.ref)) { + continue; + } + + const result = await refreshUid2Token(entry.ref.refresh_token, entry.ref.refresh_response_key); + if (result.status === "success") { + debugLog("info", `UID2: ${entry.source} refreshed`); + } else if (result.status === "optout") { + debugLog("info", `UID2: ${entry.source} opted out, removing token`); + } else { + debugLog("warn", `UID2: ${entry.source} refresh failed (${result.reason})`, result.message); + } + + applyUid2Refresh(config, entry.source, result); + } catch (e) { + debugLog("error", `UID2: ${entry?.source} refresh error`, e); + } + } +} + +export { refreshUid2Token, applyUid2Refresh, refreshStaleUid2s, UID2_REFRESH_ENDPOINT }; export type { Uid2RefData, Uid2RefreshResult }; diff --git a/lib/core/eid-cache.md b/lib/core/eid-cache.md index 6fc6548e..8be0ec96 100644 --- a/lib/core/eid-cache.md +++ b/lib/core/eid-cache.md @@ -33,7 +33,7 @@ So anything read back out of storage is ready to merge as-is, and anything comin ## 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); 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`. +`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. 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. From e318a577b6fe15d4a2f3c9f7a8736042dbbd2e36 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 1 Oct 2026 11:45:49 -0400 Subject: [PATCH 2/2] comments --- lib/addons/uid2-refresh.test.ts | 19 ++++++++++++++++--- lib/addons/uid2-refresh.ts | 1 + 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/addons/uid2-refresh.test.ts b/lib/addons/uid2-refresh.test.ts index 2b604305..f45dea4b 100644 --- a/lib/addons/uid2-refresh.test.ts +++ b/lib/addons/uid2-refresh.test.ts @@ -354,13 +354,26 @@ describe("refreshStaleUid2s", () => { it("keeps going after a failed entry", async () => { seedCache(); - server.use(http.post(UID2_REFRESH_ENDPOINT, () => HttpResponse.error())); + const encrypted = await encryptResponse({ status: "success", body: BODY }); + server.use( + http.post(UID2_REFRESH_ENDPOINT, async ({ request }) => + (await request.text()) === "FAILING_TOKEN" ? HttpResponse.error() : new HttpResponse(encrypted, { status: 200 }) + ) + ); await expect( - refreshStaleUid2s(config, [{ source: "uidapi.com" } as unknown as StaleUid2, ...stale()]) + refreshStaleUid2s(config, [ + { source: "uidapi.com", ref: { ...STALE_REF, refresh_token: "FAILING_TOKEN" } }, + { source: "other.com", ref: STALE_REF }, + ]) ).resolves.toBeUndefined(); - expect(cachedEids().map((e) => e.source)).toEqual(["uidapi.com", "other.com"]); + const eids = cachedEids(); + expect(eids.map((e) => e.source)).toEqual(["uidapi.com", "other.com"]); + expect(eids[0].uids).toEqual([{ atype: 3, id: "OLD_TOKEN" }]); + expect(eids[1].uids).toEqual([{ atype: 3, id: BODY.advertising_token }]); + expect(new LocalStorage(config).getTargeting()?.refs).toEqual({ "uidapi.com": STALE_REF, "other.com": BODY }); + expect(events).toHaveLength(1); }); it("is a no-op for an empty list", async () => { diff --git a/lib/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts index 5a1a278a..c78898cb 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -127,6 +127,7 @@ async function refreshStaleUid2s(config: ResolvedConfig, stale: StaleUid2[]): Pr for (const entry of stale) { try { if (!isUid2RefData(entry?.ref)) { + debugLog("warn", `UID2: ${entry?.source} skipped, no usable refresh material`); continue; }