diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md index 84e0a231..3a3c4cf8 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`, `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 26f23916..5a3438ae 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,129 @@ 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, + optableCacheTargeting: "OPTABLE_RESOLVED", + } 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("updates each cache copy independently, preserving a merged public copy", () => { + seedCache(); + // 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)); + + 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"]); + expect(events).toHaveLength(1); + }); + + 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" }); + + 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..7bdb24b4 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,52 @@ async function refreshUid2Token( return { status: "success", body: parsed.body }; } -export { refreshUid2Token, UID2_REFRESH_ENDPOINT }; -export type { Uid2RefData, Uid2RefreshResult }; +// 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 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; + } + + 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; + } + + const idx = eids.findIndex((e) => e.source === source); + if (idx === -1) { + 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); + } + return true; + }); + + if (updated) { + sendTargetingUpdateEvent(config, updated); + } +} + +export { refreshUid2Token, applyUid2Refresh, UID2_REFRESH_ENDPOINT }; +export type { Uid2RefData, Uid2RefreshResult, RefreshableEID }; diff --git a/lib/core/storage.ts b/lib/core/storage.ts index f1b236ef..d91a2e7b 100644 --- a/lib/core/storage.ts +++ b/lib/core/storage.ts @@ -69,6 +69,36 @@ class LocalStorage { this.setPairIDs(targeting); } + // 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) { + const raw = this.storage.getItem(key); + if (!raw) { + continue; + } + + let targeting: TargetingResponse; + try { + targeting = JSON.parse(raw); + } catch { + // Leave an unparseable copy untouched. + continue; + } + + if (update(targeting)) { + this.storage.setItem(key, JSON.stringify(targeting)); + updated = targeting; + } + } + + return updated; + } + getSite(): SiteResponse | null { const raw = this.readStorageKeys(this.siteKeys); return raw ? JSON.parse(raw) : null;