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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1285,6 +1285,20 @@ window.optable.cmd = new OptableCommands(window.optable.cmd || []);

For the page-side stub and behaviour details, see the [command queue addon README](lib/addons/commands.md).

## EID cache merge

The EID cache merge module maintains a rolling EID cache across targeting and tokenize calls. New EIDs replace cached ones with the same source, sources absent from the new response are carried over, and UID2 EIDs past their refresh deadline are returned for the caller to refresh.

```typescript
import { mergeCache } from "@optable/web-sdk/lib/dist/core/eid-cache";

const cached = JSON.parse(localStorage.getItem("OPTABLE_RESOLVED") || "null");
const { merged, staleUid2s } = mergeCache(await sdk.targeting(), cached);
localStorage.setItem("OPTABLE_RESOLVED", JSON.stringify(merged));
```

For the merge rules and UID2 ref handling, see the [EID cache README](lib/core/eid-cache.md).

## Demo Pages

The demo pages are working examples of both `identify` and `targeting` APIs, as well as an integration with the [Google Ad Manager 360](https://admanager.google.com/home/) ad server, enabling the targeting of ads served by GAM360 to audiences activated in the [Optable](https://optable.co/) DCN.
Expand Down
2 changes: 1 addition & 1 deletion lib/addons/uid2-refresh.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,6 @@ 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.
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.
9 changes: 5 additions & 4 deletions lib/addons/uid2-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,12 @@ describe("applyUid2Refresh", () => {
user: {
data: [],
eids: [
{ source: "uidapi.com", uids: [{ atype: 3, id: "OLD_TOKEN" }], _ref: OLD_REF },
{ source: "uidapi.com", uids: [{ atype: 3, id: "OLD_TOKEN", ext: { optable: { ref: "0" } } }] },
{ source: "other.com", uids: [{ id: "KEEP" }] },
],
},
},
refs: { "0": OLD_REF },
} as unknown as TargetingResponse;
new LocalStorage(config).setTargeting(targeting);
}
Expand All @@ -168,14 +169,14 @@ describe("applyUid2Refresh", () => {
window.removeEventListener("optable-targeting:change", listener);
});

it("rewrites the EID's uids and _ref on success and sends the change event", () => {
it("rewrites the EID's uids and refs entry 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(new LocalStorage(config).getTargeting()?.refs).toEqual({ "uidapi.com": BODY });
expect(eids[1].source).toBe("other.com");
expect(events).toHaveLength(1);
});
Expand Down Expand Up @@ -206,7 +207,7 @@ describe("applyUid2Refresh", () => {
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);
expect(JSON.parse(localStorage.getItem("OPTABLE_RESOLVED") as string).refs).toEqual({ "uidapi.com": BODY });
});

it.each(["invalid_token", "expired_token"])("removes the EID on a definitive %s rejection", (reason) => {
Expand Down
51 changes: 11 additions & 40 deletions lib/addons/uid2-refresh.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,17 @@
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 { 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 = {
advertising_token: string;
refresh_token: string;
refresh_response_key: string;
refresh_from: number;
refresh_expires: number;
identity_expires: number;
};

type Uid2RefreshResult =
| { status: "success"; body: Uid2RefData }
| { 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 {
const b = body as Record<string, unknown> | null | undefined;
return (
!!b &&
typeof b.advertising_token === "string" &&
typeof b.refresh_token === "string" &&
typeof b.refresh_response_key === "string" &&
typeof b.refresh_from === "number" &&
typeof b.refresh_expires === "number" &&
typeof b.identity_expires === "number"
);
}

// Refresh responses are base64(12-byte nonce || AES-GCM ciphertext), keyed by
// the refresh_response_key issued alongside the refresh token.
//
Expand Down Expand Up @@ -99,17 +74,17 @@ 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.
* matching EID and its refs sidecar entry, optout and definitive rejections
* 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 {
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;
const eids = cached?.ortb2?.user?.eids;
// If cache does not exist don't try to set.
if (!eids) {
return false;
Expand All @@ -122,16 +97,12 @@ function applyUid2Refresh(config: ResolvedConfig, source: string, result: Uid2Re

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,
};
cached.refs = { ...cached.refs, [source]: result.body };
} else {
eids.splice(idx, 1);
if (cached.refs) {
delete cached.refs[source];
}
}
return true;
});
Expand All @@ -142,4 +113,4 @@ function applyUid2Refresh(config: ResolvedConfig, source: string, result: Uid2Re
}

export { refreshUid2Token, applyUid2Refresh, UID2_REFRESH_ENDPOINT };
export type { Uid2RefData, Uid2RefreshResult, RefreshableEID };
export type { Uid2RefData, Uid2RefreshResult };
42 changes: 42 additions & 0 deletions lib/core/eid-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# EID Cache Merge

Merge helpers for wrappers that keep a rolling EID cache (typically the `OPTABLE_RESOLVED` key in `localStorage`) across targeting and tokenize calls. Each response only covers the identifiers it resolved, so the cache is merged rather than overwritten.

## Usage

```js
import { mergeCache } from "@optable/web-sdk/lib/dist/core/eid-cache";

const cached = JSON.parse(localStorage.getItem("OPTABLE_RESOLVED") || "null");
const response = await sdk.targeting();

const { merged, staleUid2s } = mergeCache(response, cached, { maxUidsPerEid: 2 });
localStorage.setItem("OPTABLE_RESOLVED", JSON.stringify(merged));
```

## Merge rules

- New EIDs replace cached ones with the same `source`. A new EID without `uids` evicts the cached one: the response revoked that source.
- Cached EIDs from sources absent in the new response are carried over.
- Each EID keeps at most `maxUidsPerEid` UIDs (default 2).
- `ortb2.user.data` comes from the new response, falling back to the cached one.
- The inputs are never mutated. The merged cache is built from copies.
- Cached EIDs are wire EIDs: refresh material never sits on them, so every consumer — RTD, `pubProvidedId`, anything else — can hand them to bidding as-is, with nothing to strip.

## UID2 refresh material

Targeting responses carry UID2 refresh tokens in an opaque-keyed `refs` map, referenced from `uids[0].ext.optable.ref`. `mergeCache` validates those and stores them in the merged cache's `refs` sidecar keyed by EID `source`, dropping the `ext.optable.ref` pointer from the cached EIDs. Sources past their `refresh_from` are returned 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`.

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.

Caches written by earlier bundle versions carried refresh material as `_ref` on the EID; there is no read-side fallback for that shape. Such a cache simply cannot refresh its UID2 until the next targeting response repopulates the sidecar.

## API

| Export | Signature | Description |
| --------------- | ------------------------------------------------------ | -------------------------------------------------------------------- |
| `mergeCache` | `(newObj, oldObj, options?) => { merged, staleUid2s }` | Merge a fresh response into the cached one. |
| `resolveRefs` | `(eids, refs?) => Record<string, Uid2RefData>` | Build a source-keyed refs map from a response's opaque-keyed one. |
| `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. |
Loading