diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md index e5c528ce..db6981c0 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(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 d143f495..898039a8 100644 --- a/lib/core/eid-cache.md +++ b/lib/core/eid-cache.md @@ -25,7 +25,7 @@ localStorage.setItem("OPTABLE_RESOLVED", JSON.stringify(merged)); ## 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`. +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); 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.