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
12 changes: 11 additions & 1 deletion lib/addons/uid2-refresh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
132 changes: 131 additions & 1 deletion lib/addons/uid2-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
});
59 changes: 57 additions & 2 deletions lib/addons/uid2-refresh.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 };
30 changes: 30 additions & 0 deletions lib/core/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down