Skip to content
Merged
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1497,6 +1497,32 @@ setStaticMappings({

For the merge rules and a full wrapper example, see the [static mappings README](lib/core/staticMappings.md).

## ID5 resolution

`resolveId5(partnerId, options?)` resolves an [ID5](https://id5.io/) user id, loading the ID5 API on demand: QA flags first, then a local 7-day cache (its own key per partner, `OPTABLE_ID5:<partnerId>`), then a live resolution with ID5's own A/B holdout disabled. Live resolution is bounded (2s default, `timeoutMs` option) and resolves `null` on timeout, load failure, partner-id mismatch or the ID5 `"0"` placeholder. Pass the bot detection addon's `isBot` to skip live resolution for crawlers:

```javascript
import { resolveId5 } from "@optable/web-sdk/lib/dist/core/id5";
import { isBot } from "@optable/web-sdk/lib/dist/addons/botDetection";

const id5Id = await resolveId5(id5PartnerId, {
isBot,
deviceAccess: () => sdk.dcn.consent.deviceAccess,
});
```

The `optableResolveID5ID` and `optableResolveId5` [QA flags](#qa-and-debug-flags) short-circuit resolution with a test value. Concurrent calls for the same partner share one script load and resolution, and an ID5 API already on the page is reused rather than loaded again.

`deviceAccess` gates the cache on both sides: when it returns `false` the id is neither read from nor written to `localStorage`, and every call resolves live. The gate is re-checked at write time, so consent withdrawn mid-resolution stops the write. On `resolveId5` it defaults to allowed, the same posture as the SDK's own default consent, so pass `sdk.dcn.consent.deviceAccess` as above — omitting it on a page configured with `consent.cmpapi` would leave the ID5 cache ungated while every other SDK cache honours the CMP. ID5's own CMP integration separately gates its network call.

Reading the cache directly takes the same gate, as a required argument, so a cached user id cannot be read out of storage without stating a consent position:

```javascript
import { getCachedId5UserId } from "@optable/web-sdk/lib/dist/core/id5";

const cached = getCachedId5UserId(id5PartnerId, () => sdk.dcn.consent.deviceAccess);
```

## 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
4 changes: 2 additions & 2 deletions lib/core/flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ if (controlGroup === "1") {
| `optableResolve1P` | wrapper code | Resolve using a first-party test identifier. |
| `optableResolve3P` | wrapper code | Resolve using a third-party test IP. |
| `optableEnableAnalytics` | wrapper code | Force analytics on, ignoring the sampling rate. |
| `optableResolveId5` | wrapper code | Return a placeholder ID5 value without loading the ID5 API. |
| `optableResolveID5ID` | wrapper code | Return a specific ID5 value without loading the ID5 API. |
| `optableResolveId5` | `resolveId5` | Return a placeholder ID5 value without loading the ID5 API. |
| `optableResolveID5ID` | `resolveId5` | Return a specific ID5 value without loading the ID5 API. |

"Wrapper code" means the flag is recognised and persisted by the SDK, but acted on by the bundle built around it. Unknown query parameters are ignored — only the keys above are parsed.

Expand Down
301 changes: 301 additions & 0 deletions lib/core/id5.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
import { getCachedId5UserId, resolveId5, ID5_CACHE_KEY } from "./id5";
import OptableSDK from "../sdk";
import { TEST_HOST, TEST_SITE } from "../test/mocks";

// Consent granted, the posture the SDK itself defaults to.
const allow = () => true;
import { resetFlags } from "./flags";

type Id5Mock = {
debug?: boolean;
init: jest.Mock;
};

function injectedScript(): HTMLScriptElement {
const script = document.head.querySelector("script[src*='id5-sync.com']") as HTMLScriptElement;
expect(script).not.toBeNull();
return script;
}

// Simulates the ID5 API loading and invoking onUpdate on an instance that
// returns the given user id under the given partner id.
function loadId5(partnerId: number | string, userId: string | undefined): Id5Mock {
const instance = {
config: { providedOptions: { partnerId } },
getUserId: () => userId,
onUpdate: (cb: () => void) => {
cb();
return instance;
},
};
const id5: Id5Mock = { init: jest.fn(() => instance) };
(window as any).ID5 = id5;
injectedScript().onload?.(new Event("load"));
return id5;
}

beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
resetFlags();
document.head.querySelectorAll("script").forEach((s) => s.remove());
delete (window as any).ID5;
});

describe("getCachedId5UserId", () => {
it("returns a cached id within the TTL and null past it", () => {
localStorage.setItem(`${ID5_CACHE_KEY}:42`, JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() }));
expect(getCachedId5UserId(42, allow)).toBe("id5-x");

localStorage.setItem(
`${ID5_CACHE_KEY}:42`,
JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() - 8 * 24 * 60 * 60 * 1000 })
);
expect(getCachedId5UserId(42, allow)).toBeNull();
});

it("does not serve another partner's cached id", () => {
localStorage.setItem(`${ID5_CACHE_KEY}:42`, JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() }));
expect(getCachedId5UserId(99, allow)).toBeNull();
});

it("tolerates a malformed cache", () => {
localStorage.setItem(`${ID5_CACHE_KEY}:42`, "{nope");
expect(getCachedId5UserId(42, allow)).toBeNull();
});
});

describe("resolveId5", () => {
it("returns the QA id from optableResolveID5ID without loading the API", async () => {
sessionStorage.setItem("optableResolveID5ID", "qa-id5-value");
resetFlags();
await expect(resolveId5(42)).resolves.toBe("qa-id5-value");
expect(document.head.querySelector("script")).toBeNull();
});

it("does not treat optableResolveID5ID=0 as an injected id", async () => {
sessionStorage.setItem("optableResolveID5ID", "0");
resetFlags();
const pending = resolveId5(42, { timeoutMs: 20 });
expect(document.head.querySelector("script")).not.toBeNull();
await expect(pending).resolves.toBeNull();
});

it("returns the placeholder for optableResolveId5", async () => {
sessionStorage.setItem("optableResolveId5", "1");
resetFlags();
await expect(resolveId5(42)).resolves.toBe("ID5-QA");
});

it("returns the cached id without loading the API", async () => {
localStorage.setItem(`${ID5_CACHE_KEY}:42`, JSON.stringify({ userId: "cached-id5", resolvedAt: Date.now() }));
await expect(resolveId5(42)).resolves.toBe("cached-id5");
expect(document.head.querySelector("script")).toBeNull();
});

it("skips live resolution for bots", async () => {
await expect(resolveId5(42, { isBot: () => true })).resolves.toBeNull();
expect(document.head.querySelector("script")).toBeNull();
});

it("resolves a live id, caches it, and disables ID5's holdout", async () => {
const pending = resolveId5(42);
const id5 = loadId5(42, "live-id5");

await expect(pending).resolves.toBe("live-id5");
expect(getCachedId5UserId(42, allow)).toBe("live-id5");
expect(id5.init).toHaveBeenCalledWith(
expect.objectContaining({ partnerId: 42, abTesting: { enabled: false, controlGroupPct: 0 } })
);
});

it("shares one script load between concurrent callers", async () => {
const first = resolveId5(42);
const second = resolveId5(42);
loadId5(42, "live-id5");

await expect(first).resolves.toBe("live-id5");
await expect(second).resolves.toBe("live-id5");
expect(document.head.querySelectorAll("script[src*='id5-sync.com']")).toHaveLength(1);
});

it("settles null when ID5.init throws instead of stalling to the timeout", async () => {
const pending = resolveId5(42);
(window as any).ID5 = {
init: () => {
throw new Error("bad partner");
},
};
injectedScript().onload?.(new Event("load"));

await expect(pending).resolves.toBeNull();
});

it("rejects a partner id mismatch", async () => {
const pending = resolveId5(42);
loadId5(99, "live-id5");

await expect(pending).resolves.toBeNull();
expect(getCachedId5UserId(42, allow)).toBeNull();
});

it("rejects the ID5 '0' placeholder", async () => {
const pending = resolveId5(42);
loadId5(42, "0");

await expect(pending).resolves.toBeNull();
});

it("resolves null when the API fails to load", async () => {
const pending = resolveId5(42);
injectedScript().onerror?.(new Event("error"));

await expect(pending).resolves.toBeNull();
});

it("gives up after the timeout when onUpdate never fires", async () => {
await expect(resolveId5(42, { timeoutMs: 20 })).resolves.toBeNull();
});
});

describe("resolveId5 - reuse, consent and partner-id handling", () => {
it("reuses an ID5 API already on the page instead of injecting a second script", async () => {
const first = resolveId5(42, { timeoutMs: 20 });
loadId5(42, undefined);
await expect(first).resolves.toBeNull();

const second = resolveId5(42, { timeoutMs: 20 });
await expect(second).resolves.toBeNull();
expect(document.head.querySelectorAll("script[src*='id5-sync.com']")).toHaveLength(1);
});

it("skips both cache read and write when deviceAccess denies storage", async () => {
localStorage.setItem(`${ID5_CACHE_KEY}:42`, JSON.stringify({ userId: "cached-id5", resolvedAt: Date.now() }));

const pending = resolveId5(42, { deviceAccess: () => false, timeoutMs: 20 });
expect(document.head.querySelector("script")).not.toBeNull();
loadId5(42, "live-id5");

await expect(pending).resolves.toBe("live-id5");
expect(JSON.parse(localStorage.getItem(`${ID5_CACHE_KEY}:42`) || "null").userId).toBe("cached-id5");
});

it("treats a numeric and a string partner id as the same partner", async () => {
const pending = resolveId5(42);
loadId5(42, "live-id5");

await expect(pending).resolves.toBe("live-id5");
expect(getCachedId5UserId("42", allow)).toBe("live-id5");
});

it("keeps per-partner dedupe when partner ids interleave", async () => {
const first = resolveId5(1, { timeoutMs: 20 });
const other = resolveId5(2, { timeoutMs: 20 });
const again = resolveId5(1, { timeoutMs: 20 });

expect(again).toBe(first);
expect(document.head.querySelectorAll("script[src*='id5-sync.com']")).toHaveLength(2);
await Promise.all([first, other, again]);
});

it("caches an onUpdate that arrives after the timeout", async () => {
let fire = () => {};
const instance: Record<string, unknown> = {
config: { providedOptions: { partnerId: 42 } },
getUserId: () => "late-id5",
onUpdate: (cb: () => void) => {
fire = cb;
return instance;
},
};
(window as any).ID5 = { init: () => instance };

await expect(resolveId5(42, { timeoutMs: 20 })).resolves.toBeNull();
fire();
expect(getCachedId5UserId(42, allow)).toBe("late-id5");
});
});

describe("resolveId5 - per-partner cache slots", () => {
it("caches two partner ids alongside each other", async () => {
(window as any).ID5 = {
init: (opts: Record<string, unknown>) => {
const instance: Record<string, unknown> = {
config: { providedOptions: { partnerId: opts.partnerId } },
getUserId: () => `id-for-${opts.partnerId}`,
onUpdate: (cb: () => void) => {
cb();
return instance;
},
};
return instance;
},
};

await expect(resolveId5(1, { timeoutMs: 20 })).resolves.toBe("id-for-1");
await expect(resolveId5(2, { timeoutMs: 20 })).resolves.toBe("id-for-2");

expect(getCachedId5UserId(1, allow)).toBe("id-for-1");
expect(getCachedId5UserId(2, allow)).toBe("id-for-2");
expect(localStorage.getItem(`${ID5_CACHE_KEY}:1`)).not.toBeNull();
expect(localStorage.getItem(`${ID5_CACHE_KEY}:2`)).not.toBeNull();
});
});

describe("resolveId5 - documented consent wiring", () => {
it("accepts the SDK's own consent as the cache gate", async () => {
const sdk = new OptableSDK({ host: TEST_HOST, site: TEST_SITE, initPassport: false });
(window as any).ID5 = {
init: () => {
const instance: Record<string, unknown> = {
config: { providedOptions: { partnerId: 42 } },
getUserId: () => "live-id5",
onUpdate: (cb: () => void) => {
cb();
return instance;
},
};
return instance;
},
};

const id5Id = await resolveId5(42, {
isBot: () => false,
deviceAccess: () => sdk.dcn.consent.deviceAccess,
timeoutMs: 20,
});

expect(id5Id).toBe("live-id5");
expect(getCachedId5UserId(42, allow)).toBe("live-id5");
});
});

describe("getCachedId5UserId - consent gate", () => {
const deny = () => false;

it("refuses to read a cached id when device access is denied", () => {
localStorage.setItem(`${ID5_CACHE_KEY}:42`, JSON.stringify({ userId: "id5-x", resolvedAt: Date.now() }));

expect(getCachedId5UserId(42, allow)).toBe("id5-x");
expect(getCachedId5UserId(42, deny)).toBeNull();
});

it("does not write a resolved id when device access is denied", async () => {
(window as any).ID5 = {
init: () => {
const instance: Record<string, unknown> = {
config: { providedOptions: { partnerId: 42 } },
getUserId: () => "live-id5",
onUpdate: (cb: () => void) => {
cb();
return instance;
},
};
return instance;
},
};

await expect(resolveId5(42, { deviceAccess: deny, timeoutMs: 20 })).resolves.toBe("live-id5");
expect(localStorage.getItem(`${ID5_CACHE_KEY}:42`)).toBeNull();
});
});
Loading