From 38b18d0cfe0c4f776e4e4756ab38856185801952 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 3 Sep 2026 10:47:41 -0400 Subject: [PATCH] uid2: add the refreshStaleUid2s stale-token loop PRODUCT-3938 --- lib/addons/uid2-refresh.md | 11 +++- lib/addons/uid2-refresh.test.ts | 105 +++++++++++++++++++++++++++++++- lib/addons/uid2-refresh.ts | 41 ++++++++++++- lib/core/eid-cache.md | 2 +- 4 files changed, 153 insertions(+), 6 deletions(-) diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md index 3a3c4cf8..d494e045 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 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. +## refreshStaleUid2s + +```js +import { refreshStaleUid2s } from "@optable/web-sdk/lib/dist/addons/uid2-refresh"; + +const { merged, staleUid2s } = mergeCache(response, cached); +await refreshStaleUid2s(config, staleUid2s); +``` + +The ready-made loop over `mergeCache`'s `staleUid2s`: refreshes each EID's token against the operator and applies the outcome to the cache. EIDs without usable `_ref` data are skipped, failures are logged via the `optableDebug`-gated `debugLog`, and nothing throws into the host page. diff --git a/lib/addons/uid2-refresh.test.ts b/lib/addons/uid2-refresh.test.ts index 5a3438ae..dd069aa6 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 { Uid2RefData } from "../core/eid-cache"; import { DCN_DEFAULTS } from "../config"; import type { ResolvedConfig } from "../config"; import { LocalStorage } from "../core/storage"; @@ -243,3 +244,105 @@ 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 staleEid = () => ({ source: "uidapi.com", uids: [{ atype: 3, id: "OLD_TOKEN" }], _ref: STALE_REF }); + + function seedCache(): void { + const targeting = { + ortb2: { user: { data: [], eids: [staleEid(), { 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("refreshes a stale token end to end and updates the cache", async () => { + seedCache(); + respondWith(await encryptResponse({ status: "success", body: BODY })); + + await refreshStaleUid2s(config, [staleEid()]); + + const eids = cachedEids(); + 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 token on an opt-out", async () => { + seedCache(); + respondWith(await encryptResponse({ status: "optout" })); + + await refreshStaleUid2s(config, [staleEid()]); + + expect(cachedEids().map((e) => e.source)).toEqual(["other.com"]); + expect(events).toHaveLength(1); + }); + + it("skips EIDs without usable ref data", async () => { + seedCache(); + + await refreshStaleUid2s(config, [{ source: "uidapi.com" }]); + + 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, [staleEid()])).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, [staleEid()])).resolves.toBeUndefined(); + + expect(cachedEids().map((e) => e.source)).toEqual(["uidapi.com", "other.com"]); + expect(events).toHaveLength(0); + }); + + 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 80a31bd8..e32647ba 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -1,10 +1,11 @@ import type { EID } from "iab-openrtb/v26"; import { AgentType } from "iab-adcom"; import type { ResolvedConfig } from "../config"; -import { isUid2RefData } from "../core/eid-cache"; -import type { Uid2RefData } from "../core/eid-cache"; +import { getRefData, isUid2RefData } from "../core/eid-cache"; +import type { CachedEid, 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 } @@ -119,5 +120,39 @@ function applyUid2Refresh(config: ResolvedConfig, source: string, result: Uid2Re } } -export { refreshUid2Token, applyUid2Refresh, UID2_REFRESH_ENDPOINT }; +/** + * Refreshes each stale UID2 EID (as returned by mergeCache) against the + * operator and applies the outcome to the targeting cache. Never throws into + * the host page. + */ +async function refreshStaleUid2s(config: ResolvedConfig, staleEids: CachedEid[]): Promise { + if (staleEids.length) { + debugLog("info", `UID2: refreshing ${staleEids.length} stale token(s)`); + } + + // Sequential: each apply is a read-modify-write of the same cache copies. + for (const eid of staleEids) { + try { + const ref = getRefData(eid); + if (!ref) { + continue; + } + + const result = await refreshUid2Token(ref.refresh_token, ref.refresh_response_key); + if (result.status === "success") { + debugLog("info", "UID2: token refreshed"); + } else if (result.status === "optout") { + debugLog("info", "UID2: opted out, removing token"); + } else { + debugLog("warn", `UID2: refresh failed (${result.reason})`, ...(result.message ? [result.message] : [])); + } + + applyUid2Refresh(config, eid.source, result); + } catch (e) { + debugLog("error", "UID2: refresh error", e); + } + } +} + +export { refreshUid2Token, applyUid2Refresh, refreshStaleUid2s, UID2_REFRESH_ENDPOINT }; export type { Uid2RefData, Uid2RefreshResult, RefreshableEID }; diff --git a/lib/core/eid-cache.md b/lib/core/eid-cache.md index 0e85ea81..8e56ad4a 100644 --- a/lib/core/eid-cache.md +++ b/lib/core/eid-cache.md @@ -24,7 +24,7 @@ localStorage.setItem("OPTABLE_RESOLVED", JSON.stringify(merged)); ## UID2 refresh material -Targeting responses carry UID2 refresh tokens in a `refs` map, referenced from `uids[0].ext.optable.ref`. `mergeCache` validates and resolves those onto each merged EID as `_ref`, and returns UID2 EIDs past their `refresh_from` as `staleUid2s`. Refresh each with the [UID2 refresh addon](../addons/uid2-refresh.md)'s `refreshUid2Token(ref.refresh_token, ref.refresh_response_key)`; a ready-made stale-refresh loop ships in a follow-up. +Targeting responses carry UID2 refresh tokens in a `refs` map, referenced from `uids[0].ext.optable.ref`. `mergeCache` validates and resolves those onto each merged EID as `_ref`, and returns UID2 EIDs past their `refresh_from` as `staleUid2s`. Pass them to the [UID2 refresh addon](../addons/uid2-refresh.md)'s `refreshStaleUid2s(config, staleUid2s)` to refresh each in place. `_ref` is cache-only metadata: the RTD module strips it before EIDs reach bid requests.