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..f80e1486 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()`, `profile()` and `targeting()` 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()`, `targeting()` 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`, `/sync`, `/profile` and `/v2/targeting`. 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/index-nocookies.html b/demos/index-nocookies.html index 703635c7..f380ea2f 100644 --- a/demos/index-nocookies.html +++ b/demos/index-nocookies.html @@ -174,6 +174,14 @@
ID Resolution
for publishers that exclusively want to transmit PAIR identifiers to bidders. + + Optable Identity System (OIS) + + Shows the OIS ID the DCN assigned this browser and which transport carried it. With + ois: true the SDK stores the ID and replays it on the X-Optable-OID header, + so the same identity survives where the third-party OPTABLE_OID cookie is blocked. + + diff --git a/demos/index.html b/demos/index.html index f26c196a..3e2ea33c 100644 --- a/demos/index.html +++ b/demos/index.html @@ -181,6 +181,14 @@
ID Resolution
for publishers that exclusively want to transmit PAIR identifiers to bidders. + + Optable Identity System (OIS) + + Shows the OIS ID the DCN assigned this browser and which transport carried it. With + ois: true the SDK stores the ID and replays it on the X-Optable-OID header, + so the same identity survives where the third-party OPTABLE_OID cookie is blocked. + + diff --git a/demos/vanilla/nocookies/ois.html.tpl b/demos/vanilla/nocookies/ois.html.tpl new file mode 100644 index 00000000..f9f4bc1e --- /dev/null +++ b/demos/vanilla/nocookies/ois.html.tpl @@ -0,0 +1,262 @@ + + + + + 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. See the + OIS section of the README + for the full description. +

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

+ The browser attaches OPTABLE_OID on its own, so identify, profile and + targeting 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 from the device signals below 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, targeting 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. +

+
+
+ +
+
+
+ + + + +
+
+
+ +
+
+
Forwarded device signals (sig)
+

+ What forwardSignals: true sends, and what the identity above is derived from. A signal this + browser does not expose is omitted rather than sent empty. +

+
+
+
+ +
+
+
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..f635020c --- /dev/null +++ b/demos/vanilla/ois.html.tpl @@ -0,0 +1,261 @@ + + + + + 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. See the + OIS section of the README + for the full description. +

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

+ The browser attaches OPTABLE_OID on its own, so identify, profile and + targeting 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 from the device signals below 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, targeting 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. +

+
+
+ +
+
+
+ + + + +
+
+
+ +
+
+
Forwarded device signals (sig)
+

+ What forwardSignals: true sends, and what the identity above is derived from. A signal this + browser does not expose is omitted rather than sent empty. +

+
+
+
+ +
+
+
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..02be9fd3 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,20 @@ 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); + } + + // Replay the stored id so the node recognizes this browser instead of deriving + // a new one. The OPTABLE_OID cookie is separate and rides along on its own. + if (config.ois) { + const oisID = oisRequestID(config, url.pathname); + if (oisID) { + headers.set(oisHeaderName, oisID); + } } const request = new Request(url.toString(), requestInit); @@ -76,7 +87,8 @@ 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); const contentType = response.headers.get("Content-Type"); const data = contentType?.startsWith("application/json") ? await response.json() : await response.text(); @@ -97,6 +109,10 @@ async function fetch(path: string, config: ResolvedConfig, init?: RequestInit delete data.passport; } + if (config.ois) { + readOISHeader(config, new URL(request.url).pathname, response.headers); + } + return data; } diff --git a/lib/core/ois.test.ts b/lib/core/ois.test.ts new file mode 100644 index 00000000..1800c6f1 --- /dev/null +++ b/lib/core/ois.test.ts @@ -0,0 +1,220 @@ +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]; + +// Endpoints where the node derives an id, in both directions. +const HEADER_PATHS = ["/identify", "/uid2/token", "/profile", "/v2/targeting"]; + +// Endpoints that derive no id, so the header is neither sent nor read. +const NON_HEADER_PATHS = ["/config", "/witness", "/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("/v2/targeting", baseConfig, { method: "GET" }); + + 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..8ce5586e --- /dev/null +++ b/lib/core/ois.ts @@ -0,0 +1,97 @@ +// The SDK's half of the Optable Identity System: stores the OIS id the node +// derives and replays it on later requests, so a browser keeps one identity +// where the HttpOnly OPTABLE_OID cookie is blocked. + +import type { ResolvedConfig } from "../config"; +import { LocalStorage } from "./storage"; +import { generateOISKeys } from "./storage-keys"; + +// Readable on the response only because the node lists it in +// Access-Control-Expose-Headers. +const oisHeaderName = "X-Optable-OID"; + +const oisChangeEventName = "optable-ois:change"; + +// The endpoints where the node derives an id. A custom header makes a request +// non-simple, so sending it anywhere else buys a CORS preflight for nothing — +// notably /config, which runs on every page load. +const HEADER_PATHS = new Set(["/identify", "/uid2/token", "/profile", "/v2/targeting"]); + +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]; +} + +// An absent header is not an instruction to forget: the node omits it on +// endpoints that derive no id, and on requests it declines to derive for. +function readOISHeader(config: ResolvedConfig, pathname: string, headers: Headers): void { + if (!derivesOISID(pathname)) { + return; + } + + // LocalStorageProxy discards the write without consent, so bail before firing + // a change event that reports nothing changed. + if (!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 (Safari private mode); a failed write must not + // break the response. + 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 { + new LocalStorage(config).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..0985dd85 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,9 @@ class OptableSDK { private contextSent: boolean = false; private contextConfig: PageContextConfig | null = null; private contextualResponse: ContextualSegmentsResponse | null = null; - private passportNullWarned: boolean = false; - private visitorIdNullWarned: boolean = false; + // Warn once per accessor per instance, so a page polling one that is + // legitimately null before initialization does not flood the console. + private warned = new Set(); constructor(dcn: InitConfig) { this.dcn = getConfig(dcn); @@ -103,12 +107,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 +130,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 +141,30 @@ class OptableSDK { return value; } + // The stored OIS id, or null until the node returns one. Requires `ois`. + // Not the cookie identity: OPTABLE_OID is HttpOnly and unreadable from JS. + 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(), targeting() 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); + } + + // Forgets the stored id; the node issues a new one on the next call. + oisClear(): void { + clearOISID(this.dcn); + } + targetingClearCache(): void { TargetingClearCache(this.dcn); }