diff --git a/Makefile b/Makefile index 1f72432e..1c82ffbc 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,7 @@ demo-html: envsubst $(DEMO_VARS) < demos/vanilla/identify.html.tpl > demos/vanilla/identify.html envsubst $(DEMO_VARS) < demos/vanilla/witness.html.tpl > demos/vanilla/witness.html envsubst $(DEMO_VARS) < demos/vanilla/profile.html.tpl > demos/vanilla/profile.html + envsubst $(DEMO_VARS) < demos/vanilla/ois.html.tpl > demos/vanilla/ois.html envsubst $(DEMO_VARS) < demos/vanilla/targeting/gam360.html.tpl > demos/vanilla/targeting/gam360.html envsubst $(DEMO_VARS) < demos/vanilla/targeting/gam360-cached.html.tpl > demos/vanilla/targeting/gam360-cached.html envsubst $(DEMO_VARS) < demos/vanilla/targeting/gam360-adcp.html.tpl > demos/vanilla/targeting/gam360-adcp.html @@ -56,6 +57,7 @@ demo-html: envsubst $(DEMO_VARS) < demos/vanilla/nocookies/identify.html.tpl > demos/vanilla/nocookies/identify.html envsubst $(DEMO_VARS) < demos/vanilla/nocookies/witness.html.tpl > demos/vanilla/nocookies/witness.html envsubst $(DEMO_VARS) < demos/vanilla/nocookies/profile.html.tpl > demos/vanilla/nocookies/profile.html + envsubst $(DEMO_VARS) < demos/vanilla/nocookies/ois.html.tpl > demos/vanilla/nocookies/ois.html envsubst $(DEMO_VARS) < demos/vanilla/nocookies/targeting/gam360.html.tpl > demos/vanilla/nocookies/targeting/gam360.html envsubst $(DEMO_VARS) < demos/vanilla/nocookies/targeting/gam360-cached.html.tpl > demos/vanilla/nocookies/targeting/gam360-cached.html envsubst $(DEMO_VARS) < demos/vanilla/nocookies/targeting/gam360-adcp.html.tpl > demos/vanilla/nocookies/targeting/gam360-adcp.html diff --git a/README.md b/README.md index 49f26c7a..cc4e72d5 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,11 @@ JavaScript SDK for integrating with an [Optable Data Connectivity Node (DCN)](ht - [Insert oeid into your Email newsletter template](#insert-oeid-into-your-email-newsletter-template) - [Call tryIdentifyFromParams SDK API](#call-tryidentifyfromparams-sdk-api) - [Passport and Visitor ID](#passport-and-visitor-id) +- [Optable Identity System (OIS)](#optable-identity-system-ois) + - [The cookie identity needs no SDK code](#the-cookie-identity-needs-no-sdk-code) + - [The derived identity is what the SDK holds](#the-derived-identity-is-what-the-sdk-holds) + - [Reading the stored ID](#reading-the-stored-id) + - [How it travels](#how-it-travels) - [QA and debug flags](#qa-and-debug-flags) - [Multi-Node Targeting Resolver](#multi-node-targeting-resolver) - [Usage](#usage) @@ -168,6 +173,9 @@ When creating an instance of `OptableSDK`, you can pass an `InitConfig` object t - **`forwardSignals` (boolean, default: `false`)** When set to `true`, forwards soft device/browser signals (language, timezone, screen size, device memory, CPU cores) to the DCN in a `sig` request parameter. Also requires device access consent, so it is a no-op when consent is not granted. A signal the browser does not expose is omitted rather than sent empty. +- **`ois` (boolean, default: `false`)** + When set to `true`, participates in the [Optable Identity System](#optable-identity-system-ois): the SDK stores the derived OIS ID the DCN returns on the `X-Optable-OID` response header and replays it on subsequent requests, so the DCN recognizes the browser instead of deriving a new identity each visit. Pair it with `forwardSignals: true`, which sends the signals the identity is derived from. Requires a DCN node with OIS ID derivation enabled and device access consent, so it is a no-op otherwise. The `OPTABLE_OID` cookie identity is separate and needs no configuration. + These configurations allow fine-tuned control over how the `OptableSDK` interacts with the Optable DCN, ensuring compatibility with different environments and privacy settings. ## Usage Example @@ -1118,6 +1126,79 @@ If the returned value is `null`, the SDK logs a one-time warning per instance to 1. The method was called before the passport was cached (e.g. before `sdk.site()` resolved). 2. The DCN is configured to not echo the passport in response bodies, in which case the client-side cache is never populated. +## Optable Identity System (OIS) + +The Optable Identity System is a cross-tenant identity system. On a DCN node configured to use it, the OIS ID replaces the [visitor ID](#passport-and-visitor-id) as the canonical profile identifier for collected events — the DCN makes that substitution itself, based on the node's identity selector. + +An OIS-enabled node recognizes a browser two ways, and only one of them involves the SDK. + +### The cookie identity needs no SDK code + +The DCN sets an `OPTABLE_OID` cookie and the browser attaches it to every call on its own, so `identify()` and `profile()` are already attributed to it with nothing enabled client-side. + +That cookie is `HttpOnly` and scoped to `optable.co`, which has two consequences worth knowing. Its value is never readable from JavaScript — not via `document.cookie`, and not from the response, because `Set-Cookie` is a forbidden response header name. And because it is a third-party cookie for a publisher page, it is dropped wherever cross-site cookies are blocked (Safari/ITP, Firefox ETP, Chrome's third-party cookie restrictions) — a different problem from the first-party eTLD+1 case described under [Domains and Cookies](#domains-and-cookies), and one a publisher cannot configure away. When that happens the DCN cannot recognize the browser from the cookie, and the derived identity below is what carries it instead. The SDK cannot bridge that gap: if the browser is willing to send the cookie it is already doing so, and if it is not, there is nothing to forward. + +### The derived identity is what the SDK holds + +The DCN derives this identity from the device signals sent in the `sig` parameter and returns it on the `X-Optable-OID` response header. With `ois: true` the SDK stores it and replays it on the same header, so the DCN recognizes the browser rather than deriving a fresh identity on every visit. + +```javascript +const sdk = new OptableSDK({ + host: "dcn.customer.com", + site: "my-site", + ois: true, + // The identity is derived from these signals, so without them there is + // nothing to derive it from. + forwardSignals: true, +}); +``` + +Or with a script tag: + +```html + + +``` + +> :warning: **Requires DCN support.** The node must have OIS ID derivation enabled and must expose `X-Optable-OID` to the browser. The DCN also only derives the identity for requests from a residential IP, so a VPN, datacenter or office IP returns no header. On a node without it the option is inert. + +### Reading the stored ID + +```javascript +const id = sdk.oisId(); // string | null — the stored derived OIS ID +const state = sdk.oisState(); // { id, storageKey } +sdk.oisClear(); // forget it; the DCN returns a fresh derivation on the next call +``` + +The SDK dispatches an `optable-ois:change` event on `window` whenever the stored ID changes, so a page can react without polling: + +```javascript +window.addEventListener("optable-ois:change", (e) => console.log(e.detail)); +``` + +`oisId()` returns `null` until a response has returned an ID. Unlike `passport()`, that does **not** happen during initialization: `/config` derives no identity, so the first ID arrives on the first `identify()` or `profile()` call. + +### How it travels + +The ID is cached in `localStorage` under `OPTABLE_OIS_` as an opaque string, and sent back on `X-Optable-OID`. + +Both directions are limited to the endpoints where the DCN derives an identity: `/identify`, `/uid2/token` and `/profile`. It is deliberately absent from `/config` — a custom header makes a request non-simple, and adding a CORS preflight to the SDK's initialization path would cost a round trip on every page load for an endpoint that returns no ID anyway — and from `/witness`, where the DCN records an event without deriving one. + +There is no write policy to reason about. The DCN returns the identity it derived for the _current_ request rather than the one the client replayed, so as those signals drift (a new IP subnet, a browser upgrade, a resized window) the stored value simply rolls forward. The SDK stores whatever the last response returned. + +Nothing is stored and no header is sent without device access consent, so the option is a no-op when consent has not been granted. + ## QA and debug flags Flags are per-session overrides for exercising SDK behaviour that is otherwise decided automatically — forcing a split-test variant, bypassing consent, turning on verbose logging. They are set from the page URL and read back through `getFlags()`. @@ -1304,3 +1385,5 @@ docker-compose up Then head to [https://localhost:8180/](localhost:8180) to see the demo pages. You can modify the code in each demo, then run `make build` and finally refresh the demo pages to see your changes take effect. If you want to test the demos with your own DCN, make sure to update the configuration (hostname and site slug) given to the OptableSDK (see `webpack.config.js` for the react example). Note that using HTTP first-party cookies with a local instance of the demos pages pointing to an Optable DCN will not work because [https://localhost:8180/](localhost:8180) does not share the same top-level domain name `.optable.co`. We recommend using [LocalStorage](https://github.com/Optable/optable-web-sdk#localstorage) instead. + +The [Optable Identity System](#optable-identity-system-ois) demo (`/vanilla/ois.html`, or `/vanilla/nocookies/ois.html`) covers both OIS identities: it explains why the `OPTABLE_OID` cookie identity is invisible to JavaScript, and shows the derived OIS ID the DCN returned, the `localStorage` key holding it, the decoded `sig` signals it was derived from, and the `X-Optable-OID` header sent and received on each call. It needs a DCN node with OIS ID derivation enabled, and only produces an ID for requests from a residential IP. diff --git a/demos/Dockerfile b/demos/Dockerfile index 8dd75cb9..fa97d342 100644 --- a/demos/Dockerfile +++ b/demos/Dockerfile @@ -10,6 +10,7 @@ COPY --chmod=0444 ./vanilla/targeting/prebid.js ./vanilla/targeting/prebid.js COPY --chmod=0444 ./vanilla/identify.html ./vanilla/identify.html COPY --chmod=0444 ./vanilla/profile.html ./vanilla/profile.html COPY --chmod=0444 ./vanilla/witness.html ./vanilla/witness.html +COPY --chmod=0444 ./vanilla/ois.html ./vanilla/ois.html COPY --chmod=0444 ./vanilla/nocookies/targeting/gam360.html ./vanilla/nocookies/targeting/gam360.html COPY --chmod=0444 ./vanilla/nocookies/targeting/gam360-cached.html ./vanilla/nocookies/targeting/gam360-cached.html COPY --chmod=0444 ./vanilla/nocookies/targeting/gam360-adcp.html ./vanilla/nocookies/targeting/gam360-adcp.html @@ -19,6 +20,7 @@ COPY --chmod=0444 ./vanilla/nocookies/targeting/prebid.js ./vanilla/nocookies/ta COPY --chmod=0444 ./vanilla/nocookies/identify.html ./vanilla/nocookies/identify.html COPY --chmod=0444 ./vanilla/nocookies/profile.html ./vanilla/nocookies/profile.html COPY --chmod=0444 ./vanilla/nocookies/witness.html ./vanilla/nocookies/witness.html +COPY --chmod=0444 ./vanilla/nocookies/ois.html ./vanilla/nocookies/ois.html COPY --chmod=0444 ./vanilla/uid2_token/index.html ./vanilla/uid2_token/index.html COPY --chmod=0444 ./vanilla/uid2_token/login.html ./vanilla/uid2_token/login.html COPY --chmod=0444 ./vanilla/pair/index.html ./vanilla/pair/index.html diff --git a/demos/vanilla/nocookies/ois.html.tpl b/demos/vanilla/nocookies/ois.html.tpl new file mode 100644 index 00000000..42f5c427 --- /dev/null +++ b/demos/vanilla/nocookies/ois.html.tpl @@ -0,0 +1,218 @@ + + + + + Optable Web SDK Demos + + + + + + + + + + + + + + + + +
+
+
+ +
+
+
+ +
+
+

Example: Optable Identity System (OIS) using LocalStorage

+

+ An OIS-enabled DCN recognizes a browser two ways, and only one of them involves the SDK. This page shows + both. +

+
+
+ +
+
+
1. Cookie identity — nothing to do
+

+ The browser attaches OPTABLE_OID on its own, so identify and + profile are already attributed to it. It is HttpOnly, so there is deliberately + nothing to display here. Block third-party cookies and the DCN falls back to the identity below. +

+
+
+ +
+
+
2. Derived identity — stored and replayed by the SDK
+

+ Derived by the DCN and returned on the X-Optable-OID response header. With + ois: true the SDK stores it and replays it on that header. It arrives on the first + identify or profile call — not during initialization. +

+
+

+ Blank after a call? The DCN only derives this identity when ID derivation is enabled for the node + and the request comes from a residential IP — a VPN, datacenter or office IP returns no + header. It also requires the DCN to expose X-Optable-OID to the browser. +

+
+
+ +
+
+
+ + + +
+
+
+ +
+
+
Call log
+
+
+
+ +
+
+
+ Home | Contact | + Terms | + LinkedIn | + Twitter +
+
+
+
+ + + + diff --git a/demos/vanilla/ois.html.tpl b/demos/vanilla/ois.html.tpl new file mode 100644 index 00000000..b0650f0b --- /dev/null +++ b/demos/vanilla/ois.html.tpl @@ -0,0 +1,217 @@ + + + + + Optable Web SDK Demos + + + + + + + + + + + + + + + + +
+
+
+ +
+
+
+ +
+
+

Example: Optable Identity System (OIS) using cookies

+

+ An OIS-enabled DCN recognizes a browser two ways, and only one of them involves the SDK. This page shows + both. +

+
+
+ +
+
+
1. Cookie identity — nothing to do
+

+ The browser attaches OPTABLE_OID on its own, so identify and + profile are already attributed to it. It is HttpOnly, so there is deliberately + nothing to display here. Block third-party cookies and the DCN falls back to the identity below. +

+
+
+ +
+
+
2. Derived identity — stored and replayed by the SDK
+

+ Derived by the DCN and returned on the X-Optable-OID response header. With + ois: true the SDK stores it and replays it on that header. It arrives on the first + identify or profile call — not during initialization. +

+
+

+ Blank after a call? The DCN only derives this identity when ID derivation is enabled for the node + and the request comes from a residential IP — a VPN, datacenter or office IP returns no + header. It also requires the DCN to expose X-Optable-OID to the browser. +

+
+
+ +
+
+
+ + + +
+
+
+ +
+
+
Call log
+
+
+
+ +
+
+
+ Home | Contact | + Terms | + LinkedIn | + Twitter +
+
+
+
+ + + + diff --git a/lib/config.ts b/lib/config.ts index 864f6514..13ac184c 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -69,6 +69,9 @@ type InitConfig = { // Forward soft device/browser signals in the 'sig' param. Opt in; also // requires device access consent. forwardSignals?: boolean; + // Participate in the Optable Identity System (OIS). Opt in; requires an + // OIS-enabled node and device access consent. + ois?: boolean; // Timeout hint for API calls (must include unit, e.g. '100ms', '2s', '1m') // When provided, the server will attempt to answer within the given time limit. // Some APIs like targeting may return partial responses depending at which stage the timeout occurred. @@ -111,6 +114,7 @@ type ResolvedConfig = { abTests?: ABTestConfig[]; additionalTargetingSignals?: TargetingSignals; forwardSignals?: boolean; + ois?: boolean; timeout?: string; insecure?: boolean; }; @@ -149,6 +153,7 @@ function getConfig(init: InitConfig): ResolvedConfig { abTests: init.abTests, additionalTargetingSignals: init.additionalTargetingSignals, forwardSignals: init.forwardSignals, + ois: init.ois, timeout: init.timeout, insecure: init.insecure, }; diff --git a/lib/core/network.ts b/lib/core/network.ts index 50934e63..39225c75 100644 --- a/lib/core/network.ts +++ b/lib/core/network.ts @@ -2,6 +2,7 @@ import type { ResolvedConfig } from "../config"; import { default as buildInfo } from "../build.json"; import { LocalStorage } from "./storage"; import { deviceSignals } from "./signals"; +import { oisHeaderName, oisRequestID, readOISHeader } from "./ois"; function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): Request { const { host, cookies, insecure } = config; @@ -64,10 +65,22 @@ function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): const requestInit: RequestInit = { ...init }; requestInit.credentials = config.consent.deviceAccess ? "include" : "omit"; + const headers = new Headers(requestInit.headers); + requestInit.headers = headers; if (config.mockedIP) { - requestInit.headers = new Headers(requestInit.headers); - requestInit.headers.set("X-Forwarded-For", config.mockedIP); + headers.set("X-Forwarded-For", config.mockedIP); + } + + if (config.ois) { + const oisID = oisRequestID(config, url.pathname); + if (oisID) { + try { + headers.set(oisHeaderName, oisID); + } catch { + // A stored value carrying characters a header cannot hold must not brick every call. + } + } } const request = new Request(url.toString(), requestInit); @@ -76,7 +89,13 @@ function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): } async function fetch(path: string, config: ResolvedConfig, init?: RequestInit): Promise { - const response = await globalThis.fetch(buildRequest(path, config, init)); + const request = buildRequest(path, config, init); + const response = await globalThis.fetch(request); + + // Ahead of the error throw below: a non-2xx can still carry a derived id. + if (config.ois) { + readOISHeader(config, new URL(request.url).pathname, response.headers); + } const contentType = response.headers.get("Content-Type"); const data = contentType?.startsWith("application/json") ? await response.json() : await response.text(); diff --git a/lib/core/ois.test.ts b/lib/core/ois.test.ts new file mode 100644 index 00000000..bbb13282 --- /dev/null +++ b/lib/core/ois.test.ts @@ -0,0 +1,217 @@ +import { clearOISID, getOISID, getOISState, oisHeaderName, oisRequestID, readOISHeader } from "./ois"; +import { buildRequest } from "./network"; +import { generateOISKeys } from "./storage-keys"; +import { TEST_HOST, TEST_SITE } from "../test/mocks"; +import type { ResolvedConfig } from "../config"; + +const baseConfig = { + host: TEST_HOST, + site: TEST_SITE, + cookies: true, + ois: true, + consent: { deviceAccess: true }, +} as unknown as ResolvedConfig; + +const storageKey = generateOISKeys(baseConfig).write[0]; + +const HEADER_PATHS = ["/identify", "/uid2/token", "/profile"]; +const NON_HEADER_PATHS = ["/config", "/witness", "/targeting", "/v2/targeting", "/v1/resolve", "/v2/tokenize"]; + +function withHeader(id?: string): Headers { + const headers = new Headers(); + if (id !== undefined) { + headers.set(oisHeaderName, id); + } + return headers; +} + +function stored(): string | null { + return window.localStorage.getItem(storageKey); +} + +beforeEach(() => { + window.localStorage.clear(); + jest.clearAllMocks(); +}); + +describe("readOISHeader", () => { + it("stores the id the node returned", () => { + readOISHeader(baseConfig, "/identify", withHeader("ois-id-1")); + + expect(stored()).toBe("ois-id-1"); + expect(getOISID(baseConfig)).toBe("ois-id-1"); + }); + + it.each(HEADER_PATHS)("stores on %s", (path) => { + readOISHeader(baseConfig, path, withHeader("ois-id-1")); + + expect(stored()).toBe("ois-id-1"); + }); + + it.each(NON_HEADER_PATHS)("ignores a header returned on %s", (path) => { + readOISHeader(baseConfig, path, withHeader("unexpected")); + + expect(stored()).toBeNull(); + }); + + it.each(NON_HEADER_PATHS)("leaves a stored id alone on %s", (path) => { + readOISHeader(baseConfig, "/identify", withHeader("keep-me")); + + readOISHeader(baseConfig, path, withHeader("unexpected")); + + expect(stored()).toBe("keep-me"); + }); + + // The node returns the id derived for the current request, not the one replayed. + it("overwrites an existing id", () => { + readOISHeader(baseConfig, "/identify", withHeader("ois-id-1")); + readOISHeader(baseConfig, "/identify", withHeader("ois-id-2")); + + expect(stored()).toBe("ois-id-2"); + }); + + it.each([ + ["absent", undefined], + ["empty", ""], + ])("leaves the stored id alone when the header is %s", (_label, value) => { + readOISHeader(baseConfig, "/identify", withHeader("keep-me")); + + readOISHeader(baseConfig, "/identify", withHeader(value as string | undefined)); + + expect(stored()).toBe("keep-me"); + }); + + it("does not rewrite storage when the id is unchanged", () => { + readOISHeader(baseConfig, "/identify", withHeader("same-id")); + jest.clearAllMocks(); + + readOISHeader(baseConfig, "/identify", withHeader("same-id")); + + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + + it("stores nothing without device access consent", () => { + const config = { ...baseConfig, consent: { deviceAccess: false } } as ResolvedConfig; + + readOISHeader(config, "/identify", withHeader("no-consent")); + + expect(stored()).toBeNull(); + }); + + it("dispatches optable-ois:change when the id changes", () => { + const listener = jest.fn(); + window.addEventListener("optable-ois:change", listener); + + readOISHeader(baseConfig, "/identify", withHeader("ois-id-1")); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0].detail).toMatchObject({ id: "ois-id-1", storageKey }); + + window.removeEventListener("optable-ois:change", listener); + }); +}); + +describe("oisRequestID", () => { + beforeEach(() => window.localStorage.setItem(storageKey, "stored-id")); + + it.each(HEADER_PATHS)("replays on %s", (path) => { + expect(oisRequestID(baseConfig, path)).toBe("stored-id"); + }); + + it.each(NON_HEADER_PATHS)("does not replay on %s", (path) => { + expect(oisRequestID(baseConfig, path)).toBeNull(); + }); + + it("returns null when nothing is stored", () => { + window.localStorage.clear(); + + expect(oisRequestID(baseConfig, "/identify")).toBeNull(); + }); +}); + +describe("buildRequest", () => { + it.each(HEADER_PATHS)("sends the stored id on %s", (path) => { + window.localStorage.setItem(storageKey, "send-me"); + + const request = buildRequest(path, baseConfig, { method: "POST" }); + + expect(request.headers.get(oisHeaderName)).toBe("send-me"); + }); + + it.each(NON_HEADER_PATHS)("does not send on %s", (path) => { + window.localStorage.setItem(storageKey, "send-me"); + + const request = buildRequest(path, baseConfig, { method: "GET" }); + + expect(request.headers.get(oisHeaderName)).toBeNull(); + }); + + it.each([ + ["not opted in", { ois: undefined }], + ["no device access consent", { consent: { deviceAccess: false } }], + ])("does not send when %s", (_label, override) => { + window.localStorage.setItem(storageKey, "send-me"); + const config = { ...baseConfig, ...override } as unknown as ResolvedConfig; + + const request = buildRequest("/identify", config, { method: "POST" }); + + expect(request.headers.get(oisHeaderName)).toBeNull(); + }); + + // Guards against reintroducing the abandoned ois=1 param design. + it("never adds an ois query param", () => { + window.localStorage.setItem(storageKey, "send-me"); + + const request = buildRequest("/identify", baseConfig, { method: "POST" }); + + expect(new URL(request.url).searchParams.has("ois")).toBe(false); + }); + + it("preserves headers the caller supplied", () => { + window.localStorage.setItem(storageKey, "send-me"); + + const request = buildRequest("/identify", baseConfig, { + method: "POST", + headers: { Accept: "application/json" }, + }); + + expect(request.headers.get("Accept")).toBe("application/json"); + expect(request.headers.get(oisHeaderName)).toBe("send-me"); + }); +}); + +// Cannot catch the real CORS dependency: jsdom does not enforce +// Access-Control-Expose-Headers, which a browser needs the node to set. +describe("round trip", () => { + it("replays an id received on a response", () => { + readOISHeader(baseConfig, "/identify", withHeader("round-trip-id")); + + const request = buildRequest("/profile", baseConfig, { method: "POST" }); + + expect(request.headers.get(oisHeaderName)).toBe("round-trip-id"); + }); +}); + +describe("getOISState", () => { + it("reports the stored id and its key", () => { + window.localStorage.setItem(storageKey, "an-id"); + + expect(getOISState(baseConfig)).toEqual({ id: "an-id", storageKey }); + expect(storageKey).toContain("OPTABLE_OIS_"); + }); + + it("reports a null id when nothing is stored", () => { + expect(getOISState(baseConfig)).toEqual({ id: null, storageKey }); + }); +}); + +describe("clearOISID", () => { + it("forgets the stored id", () => { + window.localStorage.setItem(storageKey, "forget-me"); + + clearOISID(baseConfig); + + expect(stored()).toBeNull(); + expect(getOISID(baseConfig)).toBeNull(); + }); +}); diff --git a/lib/core/ois.ts b/lib/core/ois.ts new file mode 100644 index 00000000..86305825 --- /dev/null +++ b/lib/core/ois.ts @@ -0,0 +1,84 @@ +import type { ResolvedConfig } from "../config"; +import { LocalStorage } from "./storage"; +import { generateOISKeys } from "./storage-keys"; + +const oisHeaderName = "X-Optable-OID"; + +const oisChangeEventName = "optable-ois:change"; + +const HEADER_PATHS = new Set(["/identify", "/uid2/token", "/profile"]); + +function derivesOISID(pathname: string): boolean { + return HEADER_PATHS.has(pathname); +} + +type OISState = { + id: string | null; + storageKey: string; +}; + +function getOISID(config: ResolvedConfig): string | null { + return new LocalStorage(config).getOIS(); +} + +function oisStorageKey(config: ResolvedConfig): string { + return generateOISKeys(config).write[0]; +} + +function readOISHeader(config: ResolvedConfig, pathname: string, headers: Headers): void { + if (!derivesOISID(pathname) || !config.consent.deviceAccess) { + return; + } + + const id = headers.get(oisHeaderName); + if (!id) { + return; + } + + const storage = new LocalStorage(config); + if (storage.getOIS() === id) { + return; + } + + try { + storage.setOIS(id); + } catch { + // Storage full or blocked. + return; + } + + notifyChange(config, { id, storageKey: oisStorageKey(config) }); +} + +function oisRequestID(config: ResolvedConfig, pathname: string): string | null { + if (!derivesOISID(pathname) || !config.consent.deviceAccess) { + return null; + } + + return getOISID(config); +} + +function clearOISID(config: ResolvedConfig): void { + const storage = new LocalStorage(config); + if (storage.getOIS() === null) { + return; + } + + storage.clearOIS(); + notifyChange(config, { id: null, storageKey: oisStorageKey(config) }); +} + +function getOISState(config: ResolvedConfig): OISState { + return { id: getOISID(config), storageKey: oisStorageKey(config) }; +} + +function notifyChange(config: ResolvedConfig, state: OISState): void { + window.dispatchEvent( + new CustomEvent(oisChangeEventName, { + detail: { instance: config.node || config.host, ...state }, + }) + ); +} + +export { oisHeaderName, readOISHeader, oisRequestID, getOISID, getOISState, clearOISID }; +export type { OISState }; diff --git a/lib/core/storage-keys.test.js b/lib/core/storage-keys.test.js index d10298e2..3c5735c3 100644 --- a/lib/core/storage-keys.test.js +++ b/lib/core/storage-keys.test.js @@ -1,4 +1,10 @@ -import { generateSiteKeys, generatePassportKeys, generateTargetingKeys, encodeBase64 } from "./storage-keys"; +import { + generateSiteKeys, + generatePassportKeys, + generateTargetingKeys, + generateOISKeys, + encodeBase64, +} from "./storage-keys"; describe("Storage Key Generation", () => { const mockConfig = { @@ -23,6 +29,20 @@ describe("Storage Key Generation", () => { }); }); + test("generateOISKeys should return correct storage keys", () => { + const keysWithNodeConfig = generateOISKeys(mockConfig); + expect(keysWithNodeConfig).toEqual({ + write: ["OPTABLE_OIS_" + encodeBase64("example.com/node1")], + read: ["OPTABLE_OIS_" + encodeBase64("example.com/node1")], + }); + + const keysWithoutNodeConfig = generateOISKeys({ ...mockConfig, node: undefined }); + expect(keysWithoutNodeConfig).toEqual({ + write: ["OPTABLE_OIS_" + encodeBase64("example.com")], + read: ["OPTABLE_OIS_" + encodeBase64("example.com")], + }); + }); + test("generateTargetingKeys should return correct storage keys", () => { const keysWithNodeConfig = generateTargetingKeys(mockConfig); expect(keysWithNodeConfig).toEqual({ diff --git a/lib/core/storage-keys.ts b/lib/core/storage-keys.ts index d84a767b..f2940c86 100644 --- a/lib/core/storage-keys.ts +++ b/lib/core/storage-keys.ts @@ -41,6 +41,14 @@ function generatedPairKeys(): StorageKeys { return { write: [pairStorageKey], read: [pairStorageKey] }; } +// Generate the keys for the OIS id storage +// The keys are generated based on the host and node configs +function generateOISKeys(config: ResolvedConfig): StorageKeys { + const key = `OPTABLE_OIS_${getWriteKeyBase64FromConfig(config)}`; + + return { write: [key], read: [key] }; +} + // Generate the keys for the passport storage // The keys are generated based on the host and node configs // We need to keep backward compatibility with the legacy host cache @@ -68,4 +76,4 @@ function generatePassportKeys(config: ResolvedConfig): StorageKeys { } export type { StorageKeys }; -export { generateSiteKeys, generatedPairKeys, generatePassportKeys, generateTargetingKeys }; +export { generateSiteKeys, generatedPairKeys, generatePassportKeys, generateTargetingKeys, generateOISKeys }; diff --git a/lib/core/storage.ts b/lib/core/storage.ts index f1b236ef..54a920d7 100644 --- a/lib/core/storage.ts +++ b/lib/core/storage.ts @@ -4,6 +4,7 @@ import type { TargetingResponse } from "../edge/targeting"; import { LocalStorageProxy } from "./regs/storage"; import { generatedPairKeys, + generateOISKeys, generatePassportKeys, generateSiteKeys, generateTargetingKeys, @@ -17,6 +18,7 @@ class LocalStorage { private targetingKeys: StorageKeys; private siteKeys: StorageKeys; private pairKeys: StorageKeys; + private oisKeys: StorageKeys; private storage: LocalStorageProxy; constructor(private config: ResolvedConfig) { @@ -24,6 +26,7 @@ class LocalStorage { this.targetingKeys = generateTargetingKeys(config); this.siteKeys = generateSiteKeys(config); this.pairKeys = generatedPairKeys(); + this.oisKeys = generateOISKeys(config); this.storage = new LocalStorageProxy(this.config.consent); } @@ -55,6 +58,18 @@ class LocalStorage { } } + getOIS(): string | null { + return this.readStorageKeys(this.oisKeys); + } + + setOIS(id: string) { + this.writeToStorageKeys(this.oisKeys, id); + } + + clearOIS() { + this.clearStorageKeys(this.oisKeys); + } + getTargeting(): TargetingResponse | null { const raw = this.readStorageKeys(this.targetingKeys); return raw ? JSON.parse(raw) : null; diff --git a/lib/sdk.ts b/lib/sdk.ts index 426606c8..a70dc838 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -30,6 +30,9 @@ import { import { sha256 } from "js-sha256"; import { Tokenize, TokenizeResponse } from "./edge/tokenize"; import { LocalStorage } from "./core/storage"; +import { clearOISID, getOISID, getOISState } from "./core/ois"; +import { consoleLog } from "./core/log"; +import type { OISState } from "./core/ois"; class OptableSDK { public static version = buildInfo.version; @@ -40,8 +43,7 @@ class OptableSDK { private contextSent: boolean = false; private contextConfig: PageContextConfig | null = null; private contextualResponse: ContextualSegmentsResponse | null = null; - private passportNullWarned: boolean = false; - private visitorIdNullWarned: boolean = false; + private warned = new Set(); constructor(dcn: InitConfig) { this.dcn = getConfig(dcn); @@ -103,12 +105,20 @@ class OptableSDK { return SiteFromCache(this.dcn); } + private warnOnce(key: string, message: string): void { + if (this.warned.has(key)) { + return; + } + this.warned.add(key); + consoleLog("[Optable]", "warn", message); + } + passport(): string | null { const value = new LocalStorage(this.dcn).getPassport(); - if (value === null && !this.passportNullWarned) { - this.passportNullWarned = true; - console.warn( - "[Optable] passport() returned null. The passport is cached in localStorage once the DCN returns one. " + + if (value === null) { + this.warnOnce( + "passport", + "passport() returned null. The passport is cached in localStorage once the DCN returns one. " + "Call before initialization (await sdk.site() or sdk.targeting()) may return null, and deployments where the DCN " + "does not echo the passport in response bodies will never populate it client-side." ); @@ -118,10 +128,10 @@ class OptableSDK { visitorId(): string | null { const value = new LocalStorage(this.dcn).getVisitorId(); - if (value === null && !this.visitorIdNullWarned) { - this.visitorIdNullWarned = true; - console.warn( - "[Optable] visitorId() returned null. The visitor ID is derived from the passport JWT in localStorage. " + + if (value === null) { + this.warnOnce( + "visitorId", + "visitorId() returned null. The visitor ID is derived from the passport JWT in localStorage. " + "Call before initialization (await sdk.site() or sdk.targeting()) may return null, and deployments where the DCN " + "does not echo the passport in response bodies will never populate it client-side." ); @@ -129,6 +139,27 @@ class OptableSDK { return value; } + oisId(): string | null { + const value = getOISID(this.dcn); + if (value === null && this.dcn.ois) { + this.warnOnce( + "oisId", + "oisId() returned null. The derived OIS id is cached once the DCN returns it on the X-Optable-OID " + + "response header, which happens on the first identify() or profile() call — not during " + + "initialization. A node with OIS ID derivation disabled, or a non-residential IP, never returns one." + ); + } + return value; + } + + oisState(): OISState { + return getOISState(this.dcn); + } + + oisClear(): void { + clearOISID(this.dcn); + } + targetingClearCache(): void { TargetingClearCache(this.dcn); }