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
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 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(replaceCache(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.
139 changes: 138 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 { StaleUid2, Uid2RefData } from "../core/eid-cache";
import { DCN_DEFAULTS } from "../config";
import type { ResolvedConfig } from "../config";
import { LocalStorage } from "../core/storage";
Expand Down Expand Up @@ -244,3 +245,139 @@ 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();
const encrypted = await encryptResponse({ status: "success", body: BODY });
server.use(
http.post(UID2_REFRESH_ENDPOINT, async ({ request }) =>
(await request.text()) === "FAILING_TOKEN" ? HttpResponse.error() : new HttpResponse(encrypted, { status: 200 })
)
);

await expect(
refreshStaleUid2s(config, [
{ source: "uidapi.com", ref: { ...STALE_REF, refresh_token: "FAILING_TOKEN" } },
{ source: "other.com", ref: STALE_REF },
])
).resolves.toBeUndefined();

const eids = cachedEids();
expect(eids.map((e) => e.source)).toEqual(["uidapi.com", "other.com"]);
expect(eids[0].uids).toEqual([{ atype: 3, id: "OLD_TOKEN" }]);
expect(eids[1].uids).toEqual([{ atype: 3, id: BODY.advertising_token }]);
expect(new LocalStorage(config).getTargeting()?.refs).toEqual({ "uidapi.com": STALE_REF, "other.com": BODY });
expect(events).toHaveLength(1);
});
Comment thread
mosherBT marked this conversation as resolved.

it("is a no-op for an empty list", async () => {
await expect(refreshStaleUid2s(config, [])).resolves.toBeUndefined();
expect(events).toHaveLength(0);
});
});
39 changes: 37 additions & 2 deletions lib/addons/uid2-refresh.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down Expand Up @@ -112,5 +113,39 @@ 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<void> {
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)) {
debugLog("warn", `UID2: ${entry?.source} skipped, no usable refresh material`);
continue;
}
Comment thread
mosherBT marked this conversation as resolved.

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 };
2 changes: 1 addition & 1 deletion lib/core/eid-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ So anything read back out of storage is ready to merge as-is, and anything comin

## 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); 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`.
`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.

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 Down