Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion lib/addons/uid2-refresh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
105 changes: 104 additions & 1 deletion lib/addons/uid2-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
});
41 changes: 38 additions & 3 deletions lib/addons/uid2-refresh.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down Expand Up @@ -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<void> {
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 };
2 changes: 1 addition & 1 deletion lib/core/eid-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down