Skip to content
Merged
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
18 changes: 17 additions & 1 deletion lib/addons/uid2-refresh.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ A response that cannot be decoded or decrypted throws; error policy stays with t
```js
import { applyUid2Refresh } from "@optable/web-sdk/lib/dist/addons/uid2-refresh";

applyUid2Refresh(config, "uidapi.com", result);
applyUid2Refresh(config, "uidapi.com", result, refreshToken);
```

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 optional `refreshToken` is the token the outcome belongs to. Given one, a stored copy whose `refs` entry holds a different refresh token is left alone, so an eviction or a replacement never crosses from one identity to another. A copy carrying no refresh material for the source is still updated. Omit it and every copy holding the source is updated, which is what a caller applying a single known identity wants.

## refreshStaleUid2s

```js
Expand All @@ -44,3 +46,17 @@ 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.

## refreshStaleUid2sFromCache

```js
import { refreshStaleUid2sFromCache } from "@optable/web-sdk/lib/dist/addons/uid2-refresh";

await refreshStaleUid2sFromCache(sdk.dcn);
```

Reads every stored copy of the targeting cache and refreshes each distinct UID2 past its `refresh_from` through `refreshStaleUid2s`. Copies can hold different identities, so refreshes are keyed by refresh token: two copies holding the same token make one request, and two holding different ones are refreshed and written back separately. Use this on a page that served targeting from cache: only `mergeCache` produces a `staleUid2s` list, and it runs when a response is merged, so a page that never calls the edge has nothing to pass to `refreshStaleUid2s`. That is also the page where the cached token has had time to age.

Both copies are checked, the private `OPTABLE_TARGETING_<key>` one and the public `optableCacheTargeting` one, because they can differ: `identifyAndTokenize` writes its merged cache only to its own `cacheKey` (`OPTABLE_RESOLVED` by default), which is also where prebid RTD and `pubProvidedId` read from. For a tokenize-resolved UID2 to be refreshable, that key and `optableCacheTargeting` have to name the same thing; the config default (`optable-cache:targeting`) does not. `applyUid2Refresh` then writes each outcome back to the copies holding that refresh token.

A missing cache, an unparseable copy, a cache without refresh material, and a token not yet due are all no-ops that make no request.
170 changes: 169 additions & 1 deletion lib/addons/uid2-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { webcrypto } from "node:crypto";
import { TextDecoder } from "node:util";
import { http, HttpResponse } from "msw";
import { server } from "../test/server";
import { refreshUid2Token, applyUid2Refresh, refreshStaleUid2s, UID2_REFRESH_ENDPOINT } from "./uid2-refresh";
import {
refreshUid2Token,
applyUid2Refresh,
refreshStaleUid2s,
refreshStaleUid2sFromCache,
UID2_REFRESH_ENDPOINT,
} from "./uid2-refresh";
import type { StaleUid2, Uid2RefData } from "../core/eid-cache";
import { DCN_DEFAULTS } from "../config";
import type { ResolvedConfig } from "../config";
Expand Down Expand Up @@ -381,3 +387,165 @@ describe("refreshStaleUid2s", () => {
expect(events).toHaveLength(0);
});
});

describe("refreshStaleUid2sFromCache", () => {
const config = {
host: "uid2-from-cache-host.com",
site: "site",
consent: DCN_DEFAULTS.consent,
optableCacheTargeting: "OPTABLE_RESOLVED",
} as ResolvedConfig;

const REF: Uid2RefData = {
advertising_token: "OLD_TOKEN",
refresh_token: "REFRESH_TOKEN",
refresh_response_key: KEY_B64,
refresh_from: 1,
refresh_expires: 2734462312780,
identity_expires: 1734459312780,
};

function seed(refs: Record<string, unknown>): void {
const targeting = {
ortb2: { user: { data: [], eids: [{ source: "uidapi.com", uids: [{ atype: 3, id: "OLD_TOKEN" }] }] } },
refs,
} 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[]) ?? [];
}

let calls = 0;

beforeEach(async () => {
localStorage.clear();
calls = 0;
const encrypted = await encryptResponse({ status: "success", body: BODY });
server.use(
http.post(UID2_REFRESH_ENDPOINT, () => {
calls++;
return new HttpResponse(encrypted, { status: 200 });
})
);
});

it("refreshes a token the cache holds past its refresh point", async () => {
seed({ "uidapi.com": REF });

await refreshStaleUid2sFromCache(config);

expect(calls).toBe(1);
expect(cachedEids()[0].uids).toEqual([{ atype: 3, id: BODY.advertising_token }]);
expect(new LocalStorage(config).getTargeting()?.refs).toEqual({ "uidapi.com": BODY });
});

it("leaves a token that is not due for refresh yet", async () => {
seed({ "uidapi.com": { ...REF, refresh_from: Date.now() + 60_000 } });

await refreshStaleUid2sFromCache(config);

expect(calls).toBe(0);
expect(cachedEids()[0].uids).toEqual([{ atype: 3, id: "OLD_TOKEN" }]);
});

it("does nothing when the cache holds no refresh material", async () => {
seed({});

await refreshStaleUid2sFromCache(config);

expect(calls).toBe(0);
expect(cachedEids()[0].uids).toEqual([{ atype: 3, id: "OLD_TOKEN" }]);
});

it("does nothing when there is no cache", async () => {
await expect(refreshStaleUid2sFromCache(config)).resolves.toBeUndefined();
expect(calls).toBe(0);
});

it("refreshes a token held only by the public copy", async () => {
// identifyAndTokenize writes the merged cache to OPTABLE_RESOLVED only.
localStorage.setItem(
"OPTABLE_RESOLVED",
JSON.stringify({
ortb2: { user: { data: [], eids: [{ source: "uidapi.com", uids: [{ atype: 3, id: "OLD_TOKEN" }] }] } },
refs: { "uidapi.com": REF },
})
);

await refreshStaleUid2sFromCache(config);

expect(calls).toBe(1);
const stored = JSON.parse(localStorage.getItem("OPTABLE_RESOLVED") as string);
expect(stored.ortb2.user.eids[0].uids).toEqual([{ atype: 3, id: BODY.advertising_token }]);
expect(stored.refs).toEqual({ "uidapi.com": BODY });
});

it("skips a copy that cannot be parsed", async () => {
localStorage.setItem("OPTABLE_RESOLVED", "not json");

await expect(refreshStaleUid2sFromCache(config)).resolves.toBeUndefined();
expect(calls).toBe(0);
});

it("does not read the tokenize cache under the default optableCacheTargeting", async () => {
localStorage.setItem(
"OPTABLE_RESOLVED",
JSON.stringify({
ortb2: { user: { data: [], eids: [{ source: "uidapi.com", uids: [{ atype: 3, id: "OLD_TOKEN" }] }] } },
refs: { "uidapi.com": REF },
})
);

await refreshStaleUid2sFromCache({ ...config, optableCacheTargeting: DCN_DEFAULTS.optableCacheTargeting });

expect(calls).toBe(0);
});

it("applies each refresh only to the copy holding that token", async () => {
seed({ "uidapi.com": REF });
const privateKey = Object.keys(localStorage).find((k) => k.startsWith("OPTABLE_TARGETING_")) as string;

const other = { ...REF, advertising_token: "OTHER_TOKEN", refresh_token: "OTHER_REFRESH" };
localStorage.setItem(
"OPTABLE_RESOLVED",
JSON.stringify({
ortb2: { user: { data: [], eids: [{ source: "uidapi.com", uids: [{ atype: 3, id: "OTHER_TOKEN" }] }] } },
refs: { "uidapi.com": other },
})
);

const encrypted = await encryptResponse({ status: "success", body: BODY });
server.use(
http.post(UID2_REFRESH_ENDPOINT, async ({ request }) => {
calls++;
if ((await request.text()) === "OTHER_REFRESH") {
return HttpResponse.json({ status: "invalid_token" }, { status: 400 });
}
return new HttpResponse(encrypted, { status: 200 });
})
);

await refreshStaleUid2sFromCache(config);

expect(calls).toBe(2);
const priv = JSON.parse(localStorage.getItem(privateKey) as string);
expect(priv.ortb2.user.eids[0].uids).toEqual([{ atype: 3, id: BODY.advertising_token }]);
const pub = JSON.parse(localStorage.getItem("OPTABLE_RESOLVED") as string);
expect(pub.ortb2.user.eids).toEqual([]);
expect(pub.refs).toEqual({});
});

it("does nothing when the cache cannot be read", async () => {
const spy = jest.spyOn(LocalStorage.prototype, "getTargetingCopies").mockImplementation(() => {
throw new Error("storage unavailable");
});

await expect(refreshStaleUid2sFromCache(config)).resolves.toBeUndefined();
expect(calls).toBe(0);

spy.mockRestore();
});
});
47 changes: 42 additions & 5 deletions lib/addons/uid2-refresh.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AgentType } from "iab-adcom";
import type { ResolvedConfig } from "../config";
import { isUid2RefData } from "../core/eid-cache";
import type { StaleUid2, Uid2RefData } from "../core/eid-cache";
import { getRefData, isUid2RefData, isUid2Stale, UID2_SOURCE } from "../core/eid-cache";
import type { ResolvedCache, StaleUid2, Uid2RefData } from "../core/eid-cache";
import { LocalStorage } from "../core/storage";
import { sendTargetingUpdateEvent } from "../core/events/cache-refresh";
import { debugLog } from "../core/log";
Expand Down Expand Up @@ -79,7 +79,12 @@ const EVICTION_REASONS = new Set(["invalid_token", "expired_token"]);
* evict both, 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 {
function applyUid2Refresh(
config: ResolvedConfig,
source: string,
result: Uid2RefreshResult,
refreshToken?: string
): void {
if (result.status === "error" && !EVICTION_REASONS.has(result.reason)) {
return;
}
Expand All @@ -91,6 +96,12 @@ function applyUid2Refresh(config: ResolvedConfig, source: string, result: Uid2Re
return false;
}

// A copy holding another identity's token is not this outcome's to touch.
const held = getRefData(cached as ResolvedCache, source);
if (refreshToken !== undefined && held && held.refresh_token !== refreshToken) {
return false;
}

const idx = eids.findIndex((e) => e.source === source);
if (idx === -1) {
return false;
Expand Down Expand Up @@ -140,12 +151,38 @@ async function refreshStaleUid2s(config: ResolvedConfig, stale: StaleUid2[]): Pr
debugLog("warn", `UID2: ${entry.source} refresh failed (${result.reason})`, result.message);
}

applyUid2Refresh(config, entry.source, result);
applyUid2Refresh(config, entry.source, result, entry.ref.refresh_token);
} catch (e) {
debugLog("error", `UID2: ${entry?.source} refresh error`, e);
}
}
}

export { refreshUid2Token, applyUid2Refresh, refreshStaleUid2s, UID2_REFRESH_ENDPOINT };
/**
* A page that serves targeting from cache merges no response, so it has no
* mergeCache result to take a staleUid2s list from.
*/
async function refreshStaleUid2sFromCache(config: ResolvedConfig): Promise<void> {
// identifyAndTokenize writes only to its own cacheKey, so its copy is read
// here only when optableCacheTargeting names that same key.
const stale = new Map<string, Uid2RefData>();
try {
for (const cached of new LocalStorage(config).getTargetingCopies() as ResolvedCache[]) {
const ref = isUid2Stale(cached) ? getRefData(cached, UID2_SOURCE) : null;
if (ref) {
stale.set(ref.refresh_token, ref);
}
}
} catch (e) {
debugLog("error", "UID2: could not read the targeting cache", e);
return;
}

await refreshStaleUid2s(
config,
[...stale.values()].map((ref) => ({ source: UID2_SOURCE, ref }))
);
}

export { refreshUid2Token, applyUid2Refresh, refreshStaleUid2s, refreshStaleUid2sFromCache, UID2_REFRESH_ENDPOINT };
export type { Uid2RefData, Uid2RefreshResult };
3 changes: 2 additions & 1 deletion lib/core/eid-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Listing a publisher's own domains is what lets a targeting call resolved with `s

## 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); 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.
`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. On a page that merges no response, there is no such list; the addon's `refreshStaleUid2sFromCache(config)` reads the stored cache instead and refreshes what it finds.

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.

Expand All @@ -60,3 +60,4 @@ Caches written by earlier wrappers carried refresh material as `_ref` on the EID
| `getRefData` | `(cache, source) => Uid2RefData \| null` | The source's refs entry when it can drive a refresh. |
| `isUid2Stale` | `(cache, source?) => boolean` | True when the source's ref is past `refresh_from`. Defaults to UID2. |
| `isUid2RefData` | `(value) => value is Uid2RefData` | Shape guard for refresh material. |
| `UID2_SOURCE` | `"uidapi.com"` | The EID source UID2 refresh material is keyed under. |
1 change: 1 addition & 0 deletions lib/core/eid-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,4 +236,5 @@ function stripRefPointer(uid: CachedUid): CachedUid {
return copy;
}

export { UID2_SOURCE };
export type { CachedEid, ResolvedCache, StaleUid2, Uid2RefData };
29 changes: 27 additions & 2 deletions lib/core/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,39 @@ class LocalStorage {
this.setPairIDs(targeting);
}

// The private key is in both lists, and the public key can be unset.
private targetingCopyKeys(): string[] {
return [...new Set([...this.targetingKeys.read, ...this.targetingKeys.write])].filter(Boolean);
}

// Copies can hold different representations, so a caller that inspects the
// cache has to see all of them.
getTargetingCopies(): TargetingResponse[] {
const copies: TargetingResponse[] = [];

for (const key of this.targetingCopyKeys()) {
const raw = this.storage.getItem(key);
if (!raw) {
continue;
}

try {
copies.push(JSON.parse(raw));
} catch {
// Skip an unparseable copy.
}
}

return copies;
}

// 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) {
for (const key of this.targetingCopyKeys()) {
const raw = this.storage.getItem(key);
if (!raw) {
continue;
Expand Down