Skip to content
Open
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
26 changes: 2 additions & 24 deletions lib/addons/uid2-refresh.ts
Original file line number Diff line number Diff line change
@@ -1,20 +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 { 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" }
Expand All @@ -24,19 +15,6 @@ 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
38 changes: 38 additions & 0 deletions lib/core/eid-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 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, so a targeting response that is also fed to bidding never picks up `_ref` refresh material or truncated `uids`.

## 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.

`_ref` is cache-only metadata: the RTD module strips it before EIDs reach bid requests.

## API

| Export | Signature | Description |
| ------------- | ------------------------------------------------------ | ------------------------------------------------------------- |
| `mergeCache` | `(newObj, oldObj, options?) => { merged, staleUid2s }` | Merge a fresh response into the cached one. |
| `resolveRefs` | `(eids, refs?) => void` | Stamp validated `refs` entries onto EIDs as `_ref`, in place. |
| `getRefData` | `(eid) => Uid2RefData \| null` | The EID's `_ref` when it can drive a refresh. |
| `isUid2Stale` | `(eid) => boolean` | True when the EID's ref is past `refresh_from`. |
166 changes: 166 additions & 0 deletions lib/core/eid-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { getRefData, isUid2Stale, mergeCache, resolveRefs } from "./eid-cache";

const ref = (over: Record<string, unknown> = {}) => ({
advertising_token: "adv",
refresh_token: "rt",
refresh_response_key: "rk",
refresh_from: Date.now() + 60_000,
refresh_expires: Date.now() + 120_000,
identity_expires: Date.now() + 120_000,
...over,
});

const eid = (source: string, over: Record<string, unknown> = {}) => ({
source,
uids: [{ id: `${source}-id`, atype: 3 }],
...over,
});

const cache = (eids: unknown[], over: Record<string, unknown> = {}) => ({
ortb2: { user: { data: [], eids } },
...over,
});

describe("resolveRefs", () => {
it("stamps ref data onto EIDs that reference the refs map", () => {
const uid2 = { source: "uidapi.com", uids: [{ id: "x", ext: { optable: { ref: "0" } } }] };
const other = eid("liveramp.com");
const refs = { "0": ref() };
resolveRefs([uid2, other] as any, refs as any);
expect((uid2 as any)._ref).toBe(refs["0"]);
expect((other as any)._ref).toBeUndefined();
});

it("is a no-op without a refs map", () => {
const e = eid("uidapi.com");
expect(() => resolveRefs([e] as any)).not.toThrow();
expect((e as any)._ref).toBeUndefined();
});
});

describe("getRefData", () => {
it("returns the ref only when it can drive a refresh", () => {
expect(getRefData(eid("uidapi.com", { _ref: ref() }) as any)).not.toBeNull();
expect(getRefData(eid("uidapi.com", { _ref: ref({ refresh_token: "" }) }) as any)).toBeNull();
expect(getRefData(eid("uidapi.com") as any)).toBeNull();
});
});

describe("isUid2Stale", () => {
it("is true past refresh_from and false before", () => {
expect(isUid2Stale(eid("uidapi.com", { _ref: ref({ refresh_from: Date.now() - 1 }) }) as any)).toBe(true);
expect(isUid2Stale(eid("uidapi.com", { _ref: ref() }) as any)).toBe(false);
expect(isUid2Stale(eid("uidapi.com") as any)).toBe(false);
});

it("treats a ref without refresh_from as stale", () => {
expect(isUid2Stale(eid("uidapi.com", { _ref: ref({ refresh_from: undefined }) }) as any)).toBe(true);
});
});

describe("mergeCache", () => {
it("replaces cached EIDs by source and carries over the rest", () => {
const oldCache = cache([eid("uidapi.com", { uids: [{ id: "old" }] }), eid("liveramp.com")]);
const newCache = cache([eid("uidapi.com", { uids: [{ id: "new" }] }), eid("id5-sync.com")]);
const { merged } = mergeCache(newCache as any, oldCache as any);
const eids = merged.ortb2?.user?.eids || [];
expect(eids.map((e) => e.source).sort()).toEqual(["id5-sync.com", "liveramp.com", "uidapi.com"]);
expect(eids.find((e) => e.source === "uidapi.com")?.uids?.[0]?.id).toBe("new");
});

it("drops EIDs without uids", () => {
const { merged } = mergeCache(
cache([eid("a", { uids: [] })]) as any,
cache([eid("b", { uids: undefined })]) as any
);
expect(merged.ortb2?.user?.eids).toEqual([]);
});

it("truncates uids to the default of 2 and honors maxUidsPerEid", () => {
const three = eid("a", { uids: [{ id: "1" }, { id: "2" }, { id: "3" }] });
expect(mergeCache(cache([three]) as any, null).merged.ortb2?.user?.eids?.[0]?.uids).toHaveLength(2);
const again = eid("a", { uids: [{ id: "1" }, { id: "2" }, { id: "3" }] });
expect(
mergeCache(cache([again]) as any, null, { maxUidsPerEid: 1 }).merged.ortb2?.user?.eids?.[0]?.uids
).toHaveLength(1);
});

it("resolves refs from the new response and collects stale UID2 EIDs", () => {
const uid2 = { source: "uidapi.com", uids: [{ id: "x", ext: { optable: { ref: "0" } } }] };
const newCache = cache([uid2], { refs: { "0": ref({ refresh_from: Date.now() - 1 }) } });
const { merged, staleUid2s } = mergeCache(newCache as any, null);
expect(staleUid2s).toHaveLength(1);
expect(staleUid2s[0].source).toBe("uidapi.com");
expect(merged.ortb2?.user?.eids).toHaveLength(1);
});

it("does not flag fresh UID2 EIDs or stale non-UID2 sources", () => {
const freshUid2 = eid("uidapi.com", { _ref: ref() });
const staleOther = eid("liveramp.com", { _ref: ref({ refresh_from: Date.now() - 1 }) });
const { staleUid2s } = mergeCache(cache([freshUid2, staleOther]) as any, null);
expect(staleUid2s).toEqual([]);
});

it("prefers new user data and falls back to old", () => {
const oldCache = { ortb2: { user: { data: [{ old: true }], eids: [] } } };
const newCache = { ortb2: { user: { data: [{ fresh: true }], eids: [] } } };
expect(mergeCache(newCache as any, oldCache as any).merged.ortb2?.user?.data).toEqual([{ fresh: true }]);
expect(mergeCache({ ortb2: { user: { eids: [] } } } as any, oldCache as any).merged.ortb2?.user?.data).toEqual([
{ old: true },
]);
});

it("tolerates null inputs", () => {
const { merged, staleUid2s } = mergeCache(null, undefined);
expect(merged.ortb2?.user?.eids).toEqual([]);
expect(staleUid2s).toEqual([]);
});

it("does not mutate the caller's response", () => {
const uid2 = {
source: "uidapi.com",
uids: [{ id: "1", ext: { optable: { ref: "0" } } }, { id: "2" }, { id: "3" }],
};
const newCache = cache([uid2], { refs: { "0": ref() } });

const { merged } = mergeCache(newCache as any, null);

expect(uid2.uids).toHaveLength(3);
expect("_ref" in uid2).toBe(false);
const mergedEid = merged.ortb2?.user?.eids?.[0];
expect(mergedEid?.uids).toHaveLength(2);
expect(mergedEid?._ref).toBeDefined();
});

it("collects a stale UID2 carried over from the old cache after a JSON round-trip", () => {
const oldCache = JSON.parse(
JSON.stringify(cache([eid("uidapi.com", { _ref: ref({ refresh_from: Date.now() - 1 }) })]))
);

const { merged, staleUid2s } = mergeCache(cache([eid("liveramp.com")]) as any, oldCache);

expect(staleUid2s).toHaveLength(1);
expect(staleUid2s[0]._ref?.refresh_token).toBe("rt");
expect(merged.ortb2?.user?.eids?.map((e) => e.source).sort()).toEqual(["liveramp.com", "uidapi.com"]);
});

it("a new EID without uids evicts the cached EID for that source", () => {
const oldCache = cache([eid("uidapi.com"), eid("liveramp.com")]);
const newCache = cache([{ source: "uidapi.com", uids: [] }]);

const { merged } = mergeCache(newCache as any, oldCache as any);

expect(merged.ortb2?.user?.eids?.map((e) => e.source)).toEqual(["liveramp.com"]);
});

it("ignores malformed and inherited-key refs", () => {
const badShape = { source: "uidapi.com", uids: [{ id: "x", ext: { optable: { ref: "0" } } }] };
const inherited = { source: "id5-sync.com", uids: [{ id: "y", ext: { optable: { ref: "constructor" } } }] };
const newCache = cache([badShape, inherited], { refs: { "0": { refresh_token: "rt" } } });

const { merged, staleUid2s } = mergeCache(newCache as any, null);

expect(staleUid2s).toEqual([]);
merged.ortb2?.user?.eids?.forEach((e) => expect(e._ref).toBeUndefined());
});
});
Loading