From 433164e5329ddaeeea75fe3ce847846d25464af9 Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 24 Aug 2026 15:29:17 -0400 Subject: [PATCH 1/6] ois: store and replay the OIS id when third-party cookies are blocked --- Makefile | 2 + README.md | 108 ++++++++++ demos/Dockerfile | 2 + demos/index-nocookies.html | 8 + demos/index.html | 8 + demos/vanilla/nocookies/ois.html.tpl | 289 +++++++++++++++++++++++++++ demos/vanilla/ois.html.tpl | 288 ++++++++++++++++++++++++++ lib/config.ts | 9 + lib/core/network.ts | 19 ++ lib/core/ois.test.ts | 239 ++++++++++++++++++++++ lib/core/ois.ts | 249 +++++++++++++++++++++++ lib/core/storage-keys.test.js | 22 +- lib/core/storage-keys.ts | 10 +- lib/core/storage.ts | 17 ++ lib/sdk.ts | 30 +++ 15 files changed, 1298 insertions(+), 2 deletions(-) create mode 100644 demos/vanilla/nocookies/ois.html.tpl create mode 100644 demos/vanilla/ois.html.tpl create mode 100644 lib/core/ois.test.ts create mode 100644 lib/core/ois.ts 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..952a044b 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) + - [Enabling OIS](#enabling-ois) + - [Reading the OIS ID](#reading-the-ois-id) + - [How the ID is stored and replayed](#how-the-id-is-stored-and-replayed) + - [Which transport is in use](#which-transport-is-in-use) - [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 persists the OIS ID the DCN reports and replays it on subsequent requests, so the browser keeps one identity where the third-party `OPTABLE_OID` cookie is unavailable. Requires an OIS-enabled DCN node and device access consent, so it is a no-op otherwise. + 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,104 @@ 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. + +The DCN normally carries the OIS ID in an `OPTABLE_OID` cookie. That cookie is scoped to `Domain=optable.co` with `SameSite=None`, which makes it a **third-party** cookie for a publisher page, so it is dropped wherever cross-site cookies are blocked (Safari/ITP, Firefox ETP, Chrome's third-party cookie restrictions). When it is dropped, the DCN cannot recognize the browser and mints a throwaway ID on every request. + +Enabling `ois` closes that gap: the SDK stores the ID the DCN reports and replays it on an `X-Optable-OID` request header, so the browser presents the same identity whether or not the cookie survives. + +> :warning: **Requires an OIS-enabled node.** On a node that does not use OIS the DCN returns no OIS ID and the option is inert. Ask your Optable contact whether your node has OIS enabled. + +### Enabling OIS + +```javascript +const sdk = new OptableSDK({ + host: "dcn.customer.com", + site: "my-site", + ois: true, + // Optional, but recommended alongside OIS: lets the DCN derive a + // fingerprint-based ID for browsers with no usable storage at all. + forwardSignals: true, +}); +``` + +Or with a script tag: + +```html + + +``` + +Nothing is stored and no header is sent without device access consent, so the option is also a no-op when consent has not been granted. + +### Reading the OIS ID + +```javascript +const id = sdk.oisId(); // string | null — the stored OIS ID +const state = sdk.oisState(); // full state, including which transport carried it +sdk.oisClear(); // forget the stored ID; the DCN issues a new one on the next call +``` + +`oisState()` returns: + +```typescript +type OISState = { + id: string | null; + source: "cookie" | "header" | "minted" | null; // transport the DCN last resolved from + transport: "cookie" | "localstorage" | "unknown"; + storageKey: string; + storageWritable: boolean | null; // null until a write has been attempted + updatedAt: number | null; +}; +``` + +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)); +``` + +Like `passport()` and `visitorId()`, these return `null` until a DCN response has reported an ID. By default (`initPassport: true`) the `/config` call made at construction time is the first response to carry one. + +### How the ID is stored and replayed + +The ID is cached in `localStorage` under `OPTABLE_OIS_`, alongside the transport the DCN reported it from. The DCN returns that transport as `oid_source`, and the SDK uses it to decide whether to overwrite what it already holds: + +| `oid_source` | Meaning | Action | +| ------------ | ---------------------------------------------------- | ---------------------------------------- | +| `cookie` | The `OPTABLE_OID` cookie reached the DCN | Always stored | +| `header` | The DCN used the ID the SDK replayed | Nothing to do | +| `minted` | Neither transport carried an ID, so the DCN made one | Stored **only** if nothing is stored yet | + +Storing the cookie's own value is what makes the identity survive third-party cookies being turned off later: the fallback replays the same ID the cookie was carrying, rather than introducing a new one. The `minted` rule is what keeps the ID stable — a mint arrives on every request to an endpoint that does not replay the header, so treating it as authoritative would churn the ID on each page load. + +The header is sent on `/identify`, `/sync`, `/uid2/token`, `/v2/targeting`, `/witness` and `/profile`. It is deliberately **not** sent on `/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. + +The DCN reads the cookie **before** the header, so replaying an ID never overrides a cookie that did arrive. The SDK always sends the header when it holds an ID and lets the DCN arbitrate. + +The `ois_id` and `oid_source` fields are removed from the response payload once consumed, for the same reason the passport is: a targeting response is handed to ad servers and written to the targeting cache. + +### Which transport is in use + +`OPTABLE_OID` is `HttpOnly`, so JavaScript can never read it and a browser cannot observe for itself whether the cookie was sent. `transport` is therefore derived from what the DCN reports, not guessed: + +- **`cookie`** — the cookie reached the DCN on the last call. +- **`localstorage`** — the cookie did not arrive and the DCN used the replayed ID. +- **`unknown`** — nothing stored yet, or the DCN minted an ID. A mint happens both on a first visit and when the cookie is blocked, and those are indistinguishable from the browser, so this case does not claim either. + ## 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 +1410,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`) shows the OIS ID assigned to the browser, which transport carried it, and the decoded `sig` signals, and logs the `X-Optable-OID` header sent on each call. It requires an OIS-enabled DCN node. Because the demo is served from a different site than the DCN, `OPTABLE_OID` is a third-party cookie there — which is what makes it a realistic test: allow third-party cookies to see the `cookie` transport, block them to see the `localstorage` fallback take over while the ID stays the same. 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..c4334674 --- /dev/null +++ b/demos/vanilla/nocookies/ois.html.tpl @@ -0,0 +1,289 @@ + + + + + Optable Web SDK Demos + + + + + + + + + + + + + + + + +
+
+
+ +
+
+
+ +
+
+

Example: Optable Identity System (OIS) using LocalStorage

+

+ The DCN assigns this browser an OIS ID and returns it as ois_id, alongside + oid_source naming the transport it was resolved from. With ois: true the SDK stores + that ID and replays it on the X-Optable-OID header, so this browser keeps the same identity + where the OPTABLE_OID cookie is blocked. +

+

+ The OPTABLE_OID cookie is HttpOnly and scoped to Domain=optable.co, so + JavaScript can never read it. Everything below comes from the response body. The DCN reads the cookie + before the header, so the header is a fallback and never an override. This page is served from a + different site than the DCN, so the cookie is a third-party cookie here — exactly the situation a + publisher is in. +

+
+
+ +
+
+
+ + + + +
+
+
+ +
+
+
OIS state
+
+
+
+ +
+
+
Forwarded device signals (sig)
+

+ Decoded from the sig param the SDK forwards when forwardSignals: true. The DCN uses + these to derive a fingerprint-based OIS ID for browsers with no usable storage at all. +

+
+
+
+ +
+
+
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..7946d951 --- /dev/null +++ b/demos/vanilla/ois.html.tpl @@ -0,0 +1,288 @@ + + + + + Optable Web SDK Demos + + + + + + + + + + + + + + + + +
+
+
+ +
+
+
+ +
+
+

Example: Optable Identity System (OIS) using cookies

+

+ The DCN assigns this browser an OIS ID and returns it as ois_id, alongside + oid_source naming the transport it was resolved from. With ois: true the SDK stores + that ID and replays it on the X-Optable-OID header, so this browser keeps the same identity + where the OPTABLE_OID cookie is blocked. +

+

+ The OPTABLE_OID cookie is HttpOnly and scoped to Domain=optable.co, so + JavaScript can never read it. Everything below comes from the response body. The DCN reads the cookie + before the header, so the header is a fallback and never an override. This page is served from a + different site than the DCN, so the cookie is a third-party cookie here — exactly the situation a + publisher is in. +

+
+
+ +
+
+
+ + + + +
+
+
+ +
+
+
OIS state
+
+
+
+ +
+
+
Forwarded device signals (sig)
+

+ Decoded from the sig param the SDK forwards when forwardSignals: true. The DCN uses + these to derive a fingerprint-based OIS ID for browsers with no usable storage at all. +

+
+
+
+ +
+
+
Call log
+
+
+
+ +
+
+
+ Home | Contact | + Terms | + LinkedIn | + Twitter +
+
+
+
+ + + + diff --git a/lib/config.ts b/lib/config.ts index 864f6514..47fef56a 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -69,6 +69,13 @@ 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. + // + // Persists the OIS id the node reports and replays it on the X-Optable-OID + // header, so a browser keeps one identity where the OPTABLE_OID cookie is + // blocked. The cookie still takes precedence server-side when it arrives. + 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 +118,7 @@ type ResolvedConfig = { abTests?: ABTestConfig[]; additionalTargetingSignals?: TargetingSignals; forwardSignals?: boolean; + ois?: boolean; timeout?: string; insecure?: boolean; }; @@ -149,6 +157,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..62d86c0c 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 { applyOISResponse, oisHeaderName, oisRequestID } from "./ois"; function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): Request { const { host, cookies, insecure } = config; @@ -70,6 +71,17 @@ function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): requestInit.headers.set("X-Forwarded-For", config.mockedIP); } + // Replay a stored OIS id so the node still recognizes this browser where the + // OPTABLE_OID cookie is blocked. The node reads the cookie first, so this + // never overrides a cookie that did arrive. + if (config.ois && config.consent.deviceAccess) { + const oisID = oisRequestID(config, url.pathname); + if (oisID) { + requestInit.headers = new Headers(requestInit.headers); + requestInit.headers.set(oisHeaderName, oisID); + } + } + const request = new Request(url.toString(), requestInit); return request; @@ -97,6 +109,13 @@ async function fetch(path: string, config: ResolvedConfig, init?: RequestInit delete data.passport; } + // Persist the OIS id the node reported and strip it from the payload, for the + // same reason as the passport above: a targeting response is handed to ad + // servers and written to the targeting cache. + if (config.ois && data && typeof data === "object") { + applyOISResponse(config, data); + } + return data; } diff --git a/lib/core/ois.test.ts b/lib/core/ois.test.ts new file mode 100644 index 00000000..f0f93600 --- /dev/null +++ b/lib/core/ois.test.ts @@ -0,0 +1,239 @@ +import { applyOISResponse, getOISState, oisRequestID, parseEnvelope, shouldStore } from "./ois"; +import { buildRequest } from "./network"; +import { generateOISKeys } from "./storage-keys"; +import type { ResolvedConfig } from "../config"; + +const baseConfig = { + host: "hostmock.com", + site: "site", + cookies: true, + ois: true, + consent: { deviceAccess: true }, +} as unknown as ResolvedConfig; + +const storageKey = generateOISKeys(baseConfig).write[0]; + +function stored(): { id: string; source: string } | null { + const raw = window.localStorage.getItem(storageKey); + return raw ? JSON.parse(raw) : null; +} + +function seed(id: string, source: string) { + window.localStorage.setItem(storageKey, JSON.stringify({ v: 1, id, source, ts: 1 })); +} + +beforeEach(() => { + window.localStorage.clear(); + jest.clearAllMocks(); +}); + +// The write policy is the whole reason the module does not churn the stored id. +// A minted id arrives on every request to an endpoint that does not replay the +// header (notably /config, on every page load), so treating a mint as +// authoritative would replace a good cookie-backed id constantly. +describe("shouldStore", () => { + const existing = { v: 1, id: "existing", source: "cookie" as const, ts: 1 }; + + it("always stores a cookie-sourced id", () => { + expect(shouldStore("cookie", null)).toBe(true); + expect(shouldStore("cookie", existing)).toBe(true); + }); + + it("never stores a header-sourced id, which is the one just sent", () => { + expect(shouldStore("header", null)).toBe(false); + expect(shouldStore("header", existing)).toBe(false); + }); + + it("only lets a minted id bootstrap an empty slot", () => { + expect(shouldStore("minted", null)).toBe(true); + expect(shouldStore("minted", existing)).toBe(false); + }); +}); + +// A hand-edited or truncated value must read as absent rather than throw on +// every request for the life of the browser profile. +describe("parseEnvelope", () => { + it("reads a well-formed envelope", () => { + expect(parseEnvelope('{"v":1,"id":"abc","source":"cookie","ts":7}')).toEqual({ + v: 1, + id: "abc", + source: "cookie", + ts: 7, + }); + }); + + it.each([ + ["null input", null], + ["empty string", ""], + ["not json", "not-json"], + ["a bare legacy string", '"just-an-id"'], + ["json without an id", '{"v":1,"source":"cookie"}'], + ["an empty id", '{"v":1,"id":"","source":"cookie"}'], + ])("treats %s as absent", (_label, raw) => { + expect(parseEnvelope(raw as string | null)).toBeNull(); + }); + + it("falls back to minted for an unrecognized source so the id is not trusted as a cookie", () => { + expect(parseEnvelope('{"v":1,"id":"abc","source":"wat","ts":7}')?.source).toBe("minted"); + }); +}); + +describe("applyOISResponse", () => { + it("stores a cookie-sourced id and strips both fields from the payload", () => { + const data: Record = { ois_id: "cookie-id", oid_source: "cookie", audience: [] }; + + applyOISResponse(baseConfig, data); + + expect(stored()).toMatchObject({ id: "cookie-id", source: "cookie" }); + expect(data).toEqual({ audience: [] }); + }); + + it("overwrites a bootstrapped id once the cookie transport reports one", () => { + seed("bootstrap-id", "minted"); + + applyOISResponse(baseConfig, { ois_id: "real-cookie-id", oid_source: "cookie" }); + + expect(stored()).toMatchObject({ id: "real-cookie-id", source: "cookie" }); + }); + + it("bootstraps from a minted id when nothing is stored", () => { + applyOISResponse(baseConfig, { ois_id: "minted-id", oid_source: "minted" }); + + expect(stored()).toMatchObject({ id: "minted-id", source: "minted" }); + }); + + // Without this guard every /config call would replace the id, so the browser + // would present a new identity on each page load. + it("does not let a minted id replace a stored one", () => { + seed("keep-me", "cookie"); + + applyOISResponse(baseConfig, { ois_id: "throwaway", oid_source: "minted" }); + + expect(stored()).toMatchObject({ id: "keep-me" }); + }); + + it("does not rewrite storage when the cookie reports the id already held", () => { + seed("same-id", "cookie"); + jest.clearAllMocks(); + + applyOISResponse(baseConfig, { ois_id: "same-id", oid_source: "cookie" }); + + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + + // The fields ride on /v2/targeting and /config payloads, which are handed to + // ad servers and written to the targeting cache. + it("strips the fields even when nothing is stored", () => { + const data: Record = { ois_id: "x", oid_source: "header", keywords: ["a"] }; + + applyOISResponse(baseConfig, data); + + expect(data).toEqual({ keywords: ["a"] }); + }); + + it("strips the fields when the source is unusable", () => { + const data: Record = { ois_id: "x", oid_source: "nonsense" }; + + applyOISResponse(baseConfig, data); + + expect(data).toEqual({}); + expect(stored()).toBeNull(); + }); + + it("stores nothing without device access consent", () => { + const config = { ...baseConfig, consent: { deviceAccess: false } } as ResolvedConfig; + const data: Record = { ois_id: "no-consent", oid_source: "cookie" }; + + applyOISResponse(config, data); + + expect(stored()).toBeNull(); + expect(data).toEqual({}); + expect(getOISState(config).storageWritable).toBe(false); + }); +}); + +describe("oisRequestID", () => { + beforeEach(() => seed("stored-id", "cookie")); + + it.each(["/identify", "/sync", "/uid2/token", "/v2/targeting", "/witness", "/profile"])("replays on %s", (path) => { + expect(oisRequestID(baseConfig, path)).toBe("stored-id"); + }); + + // A custom header makes the request non-simple, so replaying on /config would + // add a CORS preflight to the SDK init path on every page load. + it.each(["/config", "/v1/resolve", "/v2/tokenize", "/v1beta1/contextual"])("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(); + }); +}); + +// transport is what the demo reports. A mint means the node saw neither a cookie +// nor a header, which happens both on a first visit and when the cookie is +// blocked -- indistinguishable from the browser, so it must not claim either. +describe("getOISState transport", () => { + it.each([ + ["cookie", "cookie"], + ["header", "localstorage"], + ["minted", "unknown"], + ])("reports %s as %s", (source, transport) => { + seed("an-id", source); + expect(getOISState(baseConfig).transport).toBe(transport); + }); + + it("reports unknown with nothing stored", () => { + expect(getOISState(baseConfig)).toMatchObject({ id: null, source: null, transport: "unknown" }); + }); + + it("exposes the storage key in use", () => { + expect(getOISState(baseConfig).storageKey).toBe(storageKey); + expect(storageKey).toContain("OPTABLE_OIS_"); + }); +}); + +describe("buildRequest OIS header", () => { + it("sends a stored id on a replay path", () => { + seed("send-me", "cookie"); + + const request = buildRequest("/identify", baseConfig, { method: "POST" }); + + expect(request.headers.get("X-Optable-OID")).toBe("send-me"); + }); + + it("does not send on /config", () => { + seed("send-me", "cookie"); + + const request = buildRequest("/config", baseConfig, { method: "GET" }); + + expect(request.headers.get("X-Optable-OID")).toBeNull(); + }); + + it("does not send unless opted in", () => { + seed("send-me", "cookie"); + const config = { ...baseConfig, ois: undefined } as unknown as ResolvedConfig; + + const request = buildRequest("/identify", config, { method: "POST" }); + + expect(request.headers.get("X-Optable-OID")).toBeNull(); + }); + + it("does not send without device access consent", () => { + seed("send-me", "cookie"); + const config = { ...baseConfig, consent: { deviceAccess: false } } as ResolvedConfig; + + const request = buildRequest("/identify", config, { method: "POST" }); + + expect(request.headers.get("X-Optable-OID")).toBeNull(); + }); + + it("still resolves the path when a query string is already present", () => { + seed("send-me", "cookie"); + + const request = buildRequest("/v2/targeting?id=c%3Aabc", baseConfig, { method: "GET" }); + + expect(request.headers.get("X-Optable-OID")).toBe("send-me"); + }); +}); diff --git a/lib/core/ois.ts b/lib/core/ois.ts new file mode 100644 index 00000000..752f4033 --- /dev/null +++ b/lib/core/ois.ts @@ -0,0 +1,249 @@ +// The OIS id identifies a browser to the Optable Identity System. The edge +// normally carries it in the OPTABLE_OID cookie, but that cookie is third-party +// (Domain=optable.co, SameSite=None) so it is dropped wherever cross-site +// cookies are blocked. There the edge mints a throwaway id on every request and +// the browser is unrecognizable between calls. +// +// This module keeps the id the edge reports in localStorage and replays it on +// the X-Optable-OID header, so a browser keeps one identity when the cookie is +// unavailable. The edge reads the cookie before the header, so the header is a +// fallback and never an override. +// +// The cookie is HttpOnly, so the id can never be read from document.cookie. The +// only way a browser learns the id it was assigned is the ois_id response field. + +import type { ResolvedConfig } from "../config"; +import { LocalStorage } from "./storage"; +import { generateOISKeys } from "./storage-keys"; + +// Carries a stored id back to the edge. Already allowed by the edge CORS policy. +const oisHeaderName = "X-Optable-OID"; + +// Dispatched on window whenever the stored id changes, so a page can react +// without polling. Mirrors the targeting cache-refresh event. +const oisChangeEventName = "optable-ois:change"; + +const envelopeVersion = 1; + +// The transport the edge resolved an id from, as reported in oid_source. +type OISIDSource = "cookie" | "header" | "minted"; + +// What a browser can conclude about which transport is carrying its id. +type OISTransport = "cookie" | "localstorage" | "unknown"; + +type OISEnvelope = { + v: number; + id: string; + // The source reported when this id was stored, not necessarily the source of + // the most recent request. + source: OISIDSource; + ts: number; +}; + +type OISState = { + id: string | null; + source: OISIDSource | null; + transport: OISTransport; + storageKey: string; + // Whether the last write attempt reached localStorage: null until one has + // been attempted. Reported rather than probed, because probing would itself + // write to storage and there is no consent to do so just to answer this. + storageWritable: boolean | null; + updatedAt: number | null; +}; + +// Endpoints that replay the header. Restricted to the endpoints where the edge +// attaches the OIS id to the event it records, because a custom header makes a +// request non-simple and forces a CORS preflight. +// +// /config is deliberately absent: it runs on the SDK init path, so a preflight +// there is paid on every page load, and the edge does not record an event for +// it. Skipping it means the edge mints a throwaway id for /config on a browser +// with no cookie, which is why a minted id never overwrites a stored one. +const REPLAY_PATHS = new Set(["/identify", "/sync", "/uid2/token", "/v2/targeting", "/witness", "/profile"]); + +function isReplayPath(pathname: string): boolean { + return REPLAY_PATHS.has(pathname); +} + +function isSource(value: unknown): value is OISIDSource { + return value === "cookie" || value === "header" || value === "minted"; +} + +// A stored value is only usable if it round-trips to an envelope with an id. A +// malformed or hand-edited value is treated as absent rather than throwing. +function parseEnvelope(raw: string | null): OISEnvelope | null { + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed.id !== "string" || !parsed.id) { + return null; + } + return { + v: typeof parsed.v === "number" ? parsed.v : envelopeVersion, + id: parsed.id, + source: isSource(parsed.source) ? parsed.source : "minted", + ts: typeof parsed.ts === "number" ? parsed.ts : 0, + }; + } catch { + return null; + } +} + +function readEnvelope(config: ResolvedConfig): OISEnvelope | null { + return parseEnvelope(new LocalStorage(config).getOIS()); +} + +// Whether the last write attempt reached localStorage. Module-level so that +// state can report it without writing a probe of its own. +let lastWriteOK: boolean | null = null; + +// Reports whether the write landed. LocalStorage throws when storage is full or +// blocked (Safari private mode), and a failed write must not break the request. +function writeEnvelope(config: ResolvedConfig, envelope: OISEnvelope): boolean { + // Without device access consent LocalStorageProxy silently discards the + // write, so treat it as a failure rather than reporting a phantom success. + if (!config.consent.deviceAccess) { + lastWriteOK = false; + return false; + } + + try { + new LocalStorage(config).setOIS(JSON.stringify(envelope)); + lastWriteOK = true; + } catch { + lastWriteOK = false; + } + + return lastWriteOK; +} + +// Decides whether an id the edge just reported replaces the stored one. +// +// A cookie-sourced id is authoritative: storing it is what lets the same +// identity survive the cookie being blocked later, which is the whole point of +// the module. A header-sourced id is the one just sent, so there is nothing new +// to record. A minted id means neither transport carried an id, which happens on +// a first visit and on every request to an endpoint that does not replay the +// header, so it may only bootstrap an empty slot -- overwriting on a mint would +// churn the id on every page load. +function shouldStore(incoming: OISIDSource, stored: OISEnvelope | null): boolean { + switch (incoming) { + case "cookie": + return true; + case "header": + return false; + case "minted": + return stored === null; + } +} + +type OISResponseFields = { + ois_id?: unknown; + oid_source?: unknown; +}; + +// Consumes the ois_id/oid_source fields from a response body, applying them to +// storage and then removing them. +// +// The fields are deleted for the same reason the passport is: a /v2/targeting or +// /config payload is handed to ad servers and written to the targeting cache, so +// anything left on it leaks. +function applyOISResponse(config: ResolvedConfig, data: OISResponseFields): void { + const id = data.ois_id; + const source = data.oid_source; + + delete data.ois_id; + delete data.oid_source; + + if (typeof id !== "string" || !id || !isSource(source)) { + return; + } + + const stored = readEnvelope(config); + if (!shouldStore(source, stored)) { + return; + } + + // Storing an identical id would rewrite the same value on every request for + // the whole of a cookie-backed session. + if (stored && stored.id === id && stored.source === source) { + return; + } + + if (writeEnvelope(config, { v: envelopeVersion, id, source, ts: Date.now() })) { + notifyChange(config); + } +} + +// Returns the id to replay on this request, or null when there is nothing to +// send or the endpoint does not replay. +function oisRequestID(config: ResolvedConfig, pathname: string): string | null { + if (!isReplayPath(pathname)) { + return null; + } + + return readEnvelope(config)?.id ?? null; +} + +// A minted id means the edge saw no cookie and no header. On a first visit that +// is expected and says nothing about whether cookies work; on a later visit it +// means the cookie was dropped. The two are indistinguishable from the browser, +// so a mint reports "unknown" rather than guessing. +function transportOf(source: OISIDSource | null): OISTransport { + switch (source) { + case "cookie": + return "cookie"; + case "header": + return "localstorage"; + default: + return "unknown"; + } +} + +function getOISState(config: ResolvedConfig): OISState { + const envelope = readEnvelope(config); + + return { + id: envelope?.id ?? null, + source: envelope?.source ?? null, + transport: transportOf(envelope?.source ?? null), + storageKey: generateOISKeys(config).write[0], + storageWritable: lastWriteOK, + updatedAt: envelope?.ts ?? null, + }; +} + +function getOISID(config: ResolvedConfig): string | null { + return readEnvelope(config)?.id ?? null; +} + +function clearOISID(config: ResolvedConfig): void { + new LocalStorage(config).clearOIS(); + notifyChange(config); +} + +function notifyChange(config: ResolvedConfig): void { + try { + window.dispatchEvent(new CustomEvent(oisChangeEventName, { detail: getOISState(config) })); + } catch { + // A missing CustomEvent constructor must not break a request. + } +} + +export { + oisHeaderName, + oisChangeEventName, + applyOISResponse, + oisRequestID, + getOISState, + getOISID, + clearOISID, + shouldStore, + parseEnvelope, + isReplayPath, +}; +export type { OISState, OISEnvelope, OISIDSource, OISTransport }; 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..054e1410 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,20 @@ class LocalStorage { } } + // The OIS envelope is stored as an opaque string; lib/core/ois.ts owns its + // shape and tolerates a malformed value. + getOIS(): string | null { + return this.readStorageKeys(this.oisKeys); + } + + setOIS(envelope: string) { + this.writeToStorageKeys(this.oisKeys, envelope); + } + + 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..7d0cedc0 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -30,6 +30,8 @@ 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 type { OISState } from "./core/ois"; class OptableSDK { public static version = buildInfo.version; @@ -42,6 +44,7 @@ class OptableSDK { private contextualResponse: ContextualSegmentsResponse | null = null; private passportNullWarned: boolean = false; private visitorIdNullWarned: boolean = false; + private oisNullWarned: boolean = false; constructor(dcn: InitConfig) { this.dcn = getConfig(dcn); @@ -129,6 +132,33 @@ class OptableSDK { return value; } + // The OIS id currently stored for this node, or null when the node has not + // reported one yet. Requires the `ois` config option. + oisId(): string | null { + const value = getOISID(this.dcn); + if (value === null && this.dcn.ois && !this.oisNullWarned) { + this.oisNullWarned = true; + console.warn( + "[Optable] oisId() returned null. The OIS id is cached in localStorage once the DCN returns one. " + + "A call before initialization (await sdk.site() or sdk.targeting()) may return null, and a node that is " + + "not OIS-enabled never returns one." + ); + } + return value; + } + + // The stored OIS id plus the transport the node last resolved it from. Useful + // for confirming whether the OPTABLE_OID cookie or the localStorage fallback + // is carrying the id. + oisState(): OISState { + return getOISState(this.dcn); + } + + // Forgets the stored OIS id. The node issues a new one on the next call. + oisClear(): void { + clearOISID(this.dcn); + } + targetingClearCache(): void { TargetingClearCache(this.dcn); } From 74cb927c0348bd32d05bd2caf45707773b5f0adf Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 24 Aug 2026 15:57:08 -0400 Subject: [PATCH 2/6] ois: opt into the OIS id response with an ois=1 param --- README.md | 6 +++++- lib/core/network.ts | 9 ++++++++- lib/core/ois.test.ts | 34 ++++++++++++++++++++++++++++++++++ lib/core/ois.ts | 7 +++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 952a044b..4110ff20 100644 --- a/README.md +++ b/README.md @@ -1210,7 +1210,11 @@ The ID is cached in `localStorage` under `OPTABLE_OIS_`, al Storing the cookie's own value is what makes the identity survive third-party cookies being turned off later: the fallback replays the same ID the cookie was carrying, rather than introducing a new one. The `minted` rule is what keeps the ID stable — a mint arrives on every request to an endpoint that does not replay the header, so treating it as authoritative would churn the ID on each page load. -The header is sent on `/identify`, `/sync`, `/uid2/token`, `/v2/targeting`, `/witness` and `/profile`. It is deliberately **not** sent on `/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. +Two things go on the wire. An `ois=1` query parameter opts into receiving the ID: the DCN returns it only to a caller that asks, because it is a stable cross-site identifier — the same reason the passport is echoed in-band only when the client selects query-string transport. That parameter is sent on every request, since the first response is what bootstraps the stored ID. + +The `X-Optable-OID` header, which replays a stored ID, is sent only on `/identify`, `/sync`, `/uid2/token`, `/v2/targeting`, `/witness` and `/profile`. It is deliberately **not** sent on `/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. A query parameter has no such cost, which is why the opt-in and the replay are carried differently. + +The DCN also withholds the ID without read consent, so a visitor whose consent does not permit identity reads gets no ID and the SDK stores nothing. The DCN reads the cookie **before** the header, so replaying an ID never overrides a cookie that did arrive. The SDK always sends the header when it holds an ID and lets the DCN arbitrate. diff --git a/lib/core/network.ts b/lib/core/network.ts index 62d86c0c..b641ce15 100644 --- a/lib/core/network.ts +++ b/lib/core/network.ts @@ -2,7 +2,7 @@ import type { ResolvedConfig } from "../config"; import { default as buildInfo } from "../build.json"; import { LocalStorage } from "./storage"; import { deviceSignals } from "./signals"; -import { applyOISResponse, oisHeaderName, oisRequestID } from "./ois"; +import { applyOISResponse, oisHeaderName, oisParamName, oisRequestID } from "./ois"; function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): Request { const { host, cookies, insecure } = config; @@ -74,7 +74,14 @@ function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): // Replay a stored OIS id so the node still recognizes this browser where the // OPTABLE_OID cookie is blocked. The node reads the cookie first, so this // never overrides a cookie that did arrive. + // + // The `ois` param is a separate opt-in: the node only returns the id to a + // caller that asks for it. It is set on every request, including the ones that + // do not replay the header, because the first response is what bootstraps the + // stored id. A query param keeps the request CORS-simple, unlike the header. if (config.ois && config.consent.deviceAccess) { + url.searchParams.set(oisParamName, "1"); + const oisID = oisRequestID(config, url.pathname); if (oisID) { requestInit.headers = new Headers(requestInit.headers); diff --git a/lib/core/ois.test.ts b/lib/core/ois.test.ts index f0f93600..6dcc1642 100644 --- a/lib/core/ois.test.ts +++ b/lib/core/ois.test.ts @@ -194,6 +194,40 @@ describe("getOISState transport", () => { }); }); +describe("buildRequest OIS opt-in param", () => { + // The DCN returns the id only to a caller that asks for it, and the first + // response is what bootstraps storage — so the param must be sent even on + // endpoints that do not replay the header, and before anything is stored. + it.each(["/config", "/identify", "/v2/targeting"])("opts in on %s", (path) => { + const request = buildRequest(path, baseConfig, { method: "GET" }); + + expect(new URL(request.url).searchParams.get("ois")).toBe("1"); + }); + + it("opts in with nothing stored yet, so the id can bootstrap", () => { + const request = buildRequest("/config", baseConfig, { method: "GET" }); + + expect(new URL(request.url).searchParams.get("ois")).toBe("1"); + expect(request.headers.get("X-Optable-OID")).toBeNull(); + }); + + it("does not opt in unless enabled", () => { + const config = { ...baseConfig, ois: undefined } as unknown as ResolvedConfig; + + const request = buildRequest("/identify", config, { method: "POST" }); + + expect(new URL(request.url).searchParams.has("ois")).toBe(false); + }); + + it("does not opt in without device access consent", () => { + const config = { ...baseConfig, consent: { deviceAccess: false } } as ResolvedConfig; + + const request = buildRequest("/identify", config, { method: "POST" }); + + expect(new URL(request.url).searchParams.has("ois")).toBe(false); + }); +}); + describe("buildRequest OIS header", () => { it("sends a stored id on a replay path", () => { seed("send-me", "cookie"); diff --git a/lib/core/ois.ts b/lib/core/ois.ts index 752f4033..21bee5f6 100644 --- a/lib/core/ois.ts +++ b/lib/core/ois.ts @@ -19,6 +19,12 @@ import { generateOISKeys } from "./storage-keys"; // Carries a stored id back to the edge. Already allowed by the edge CORS policy. const oisHeaderName = "X-Optable-OID"; +// Opts into receiving the resolved OIS id in the response body. The edge returns +// the id only to a caller that asks for it, because it is a stable cross-site +// identifier — the same reason the passport is echoed in-band only when the +// client selected query-string transport. +const oisParamName = "ois"; + // Dispatched on window whenever the stored id changes, so a page can react // without polling. Mirrors the targeting cache-refresh event. const oisChangeEventName = "optable-ois:change"; @@ -236,6 +242,7 @@ function notifyChange(config: ResolvedConfig): void { export { oisHeaderName, + oisParamName, oisChangeEventName, applyOISResponse, oisRequestID, From 61a44bdfea706064533357d4e5346b739b933671 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 25 Aug 2026 14:12:57 -0400 Subject: [PATCH 3/6] ois: store the derived id from the X-Optable-OID response header --- README.md | 83 +++----- demos/vanilla/nocookies/ois.html.tpl | 197 ++++++++---------- demos/vanilla/ois.html.tpl | 197 ++++++++---------- lib/core/network.ts | 40 ++-- lib/core/ois.test.ts | 296 ++++++++++++--------------- lib/core/ois.ts | 264 ++++++------------------ lib/core/storage.ts | 17 +- lib/sdk.ts | 54 +++-- 8 files changed, 447 insertions(+), 701 deletions(-) diff --git a/README.md b/README.md index 4110ff20..f80e1486 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,10 @@ JavaScript SDK for integrating with an [Optable Data Connectivity Node (DCN)](ht - [Call tryIdentifyFromParams SDK API](#call-tryidentifyfromparams-sdk-api) - [Passport and Visitor ID](#passport-and-visitor-id) - [Optable Identity System (OIS)](#optable-identity-system-ois) - - [Enabling OIS](#enabling-ois) - - [Reading the OIS ID](#reading-the-ois-id) - - [How the ID is stored and replayed](#how-the-id-is-stored-and-replayed) - - [Which transport is in use](#which-transport-is-in-use) + - [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) @@ -174,7 +174,7 @@ When creating an instance of `OptableSDK`, you can pass an `InitConfig` object t 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 persists the OIS ID the DCN reports and replays it on subsequent requests, so the browser keeps one identity where the third-party `OPTABLE_OID` cookie is unavailable. Requires an OIS-enabled DCN node and device access consent, so it is a no-op otherwise. + 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. @@ -1130,21 +1130,25 @@ If the returned value is `null`, the SDK logs a one-time warning per instance to 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. -The DCN normally carries the OIS ID in an `OPTABLE_OID` cookie. That cookie is scoped to `Domain=optable.co` with `SameSite=None`, which makes it a **third-party** cookie for a publisher page, so it is dropped wherever cross-site cookies are blocked (Safari/ITP, Firefox ETP, Chrome's third-party cookie restrictions). When it is dropped, the DCN cannot recognize the browser and mints a throwaway ID on every request. +An OIS-enabled node recognizes a browser two ways, and only one of them involves the SDK. -Enabling `ois` closes that gap: the SDK stores the ID the DCN reports and replays it on an `X-Optable-OID` request header, so the browser presents the same identity whether or not the cookie survives. +### The cookie identity needs no SDK code -> :warning: **Requires an OIS-enabled node.** On a node that does not use OIS the DCN returns no OIS ID and the option is inert. Ask your Optable contact whether your node has OIS enabled. +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. -### Enabling OIS +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, - // Optional, but recommended alongside OIS: lets the DCN derive a - // fingerprint-based ID for browsers with no usable storage at all. + // The identity is derived from these signals, so without them there is + // nothing to derive it from. forwardSignals: true, }); ``` @@ -1167,27 +1171,14 @@ Or with a script tag: ``` -Nothing is stored and no header is sent without device access consent, so the option is also a no-op when consent has not been granted. +> :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 OIS ID +### Reading the stored ID ```javascript -const id = sdk.oisId(); // string | null — the stored OIS ID -const state = sdk.oisState(); // full state, including which transport carried it -sdk.oisClear(); // forget the stored ID; the DCN issues a new one on the next call -``` - -`oisState()` returns: - -```typescript -type OISState = { - id: string | null; - source: "cookie" | "header" | "minted" | null; // transport the DCN last resolved from - transport: "cookie" | "localstorage" | "unknown"; - storageKey: string; - storageWritable: boolean | null; // null until a write has been attempted - updatedAt: number | null; -}; +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: @@ -1196,37 +1187,17 @@ The SDK dispatches an `optable-ois:change` event on `window` whenever the stored window.addEventListener("optable-ois:change", (e) => console.log(e.detail)); ``` -Like `passport()` and `visitorId()`, these return `null` until a DCN response has reported an ID. By default (`initPassport: true`) the `/config` call made at construction time is the first response to carry one. - -### How the ID is stored and replayed - -The ID is cached in `localStorage` under `OPTABLE_OIS_`, alongside the transport the DCN reported it from. The DCN returns that transport as `oid_source`, and the SDK uses it to decide whether to overwrite what it already holds: - -| `oid_source` | Meaning | Action | -| ------------ | ---------------------------------------------------- | ---------------------------------------- | -| `cookie` | The `OPTABLE_OID` cookie reached the DCN | Always stored | -| `header` | The DCN used the ID the SDK replayed | Nothing to do | -| `minted` | Neither transport carried an ID, so the DCN made one | Stored **only** if nothing is stored yet | - -Storing the cookie's own value is what makes the identity survive third-party cookies being turned off later: the fallback replays the same ID the cookie was carrying, rather than introducing a new one. The `minted` rule is what keeps the ID stable — a mint arrives on every request to an endpoint that does not replay the header, so treating it as authoritative would churn the ID on each page load. - -Two things go on the wire. An `ois=1` query parameter opts into receiving the ID: the DCN returns it only to a caller that asks, because it is a stable cross-site identifier — the same reason the passport is echoed in-band only when the client selects query-string transport. That parameter is sent on every request, since the first response is what bootstraps the stored ID. - -The `X-Optable-OID` header, which replays a stored ID, is sent only on `/identify`, `/sync`, `/uid2/token`, `/v2/targeting`, `/witness` and `/profile`. It is deliberately **not** sent on `/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. A query parameter has no such cost, which is why the opt-in and the replay are carried differently. - -The DCN also withholds the ID without read consent, so a visitor whose consent does not permit identity reads gets no ID and the SDK stores nothing. +`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. -The DCN reads the cookie **before** the header, so replaying an ID never overrides a cookie that did arrive. The SDK always sends the header when it holds an ID and lets the DCN arbitrate. +### How it travels -The `ois_id` and `oid_source` fields are removed from the response payload once consumed, for the same reason the passport is: a targeting response is handed to ad servers and written to the targeting cache. +The ID is cached in `localStorage` under `OPTABLE_OIS_` as an opaque string, and sent back on `X-Optable-OID`. -### Which transport is in use +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. -`OPTABLE_OID` is `HttpOnly`, so JavaScript can never read it and a browser cannot observe for itself whether the cookie was sent. `transport` is therefore derived from what the DCN reports, not guessed: +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. -- **`cookie`** — the cookie reached the DCN on the last call. -- **`localstorage`** — the cookie did not arrive and the DCN used the replayed ID. -- **`unknown`** — nothing stored yet, or the DCN minted an ID. A mint happens both on a first visit and when the cookie is blocked, and those are indistinguishable from the browser, so this case does not claim either. +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 @@ -1415,4 +1386,4 @@ Then head to [https://localhost:8180/](localhost:8180) to see the demo pages. Yo 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`) shows the OIS ID assigned to the browser, which transport carried it, and the decoded `sig` signals, and logs the `X-Optable-OID` header sent on each call. It requires an OIS-enabled DCN node. Because the demo is served from a different site than the DCN, `OPTABLE_OID` is a third-party cookie there — which is what makes it a realistic test: allow third-party cookies to see the `cookie` transport, block them to see the `localstorage` fallback take over while the ID stays the same. +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/vanilla/nocookies/ois.html.tpl b/demos/vanilla/nocookies/ois.html.tpl index c4334674..f9f4bc1e 100644 --- a/demos/vanilla/nocookies/ois.html.tpl +++ b/demos/vanilla/nocookies/ois.html.tpl @@ -23,6 +23,8 @@ node: "${DCN_NODE}", cookies: false, ois: true, + // The DCN derives this identity from these signals, so + // without them there is nothing to derive it from. forwardSignals: true, }); }); @@ -46,27 +48,19 @@ table.ois th { padding: 4px 12px 4px 0; font-size: 0.85rem; - } - .badge { - display: inline-block; - padding: 1px 8px; - border-radius: 10px; - font-size: 0.75rem; - background: #e1e1e1; - } - .badge.cookie { - background: #d3ecd3; - } - .badge.localstorage { - background: #ffe7c2; - } - .badge.unknown { - background: #e1e1e1; + vertical-align: top; } .note { font-size: 0.8rem; color: #666; } + .warn { + font-size: 0.8rem; + background: #fff6e0; + border: 1px solid #f0dca8; + border-radius: 4px; + padding: 0.6rem 1rem; + } @@ -82,36 +76,51 @@

Example: Optable Identity System (OIS) using LocalStorage

- The DCN assigns this browser an OIS ID and returns it as ois_id, alongside - oid_source naming the transport it was resolved from. With ois: true the SDK stores - that ID and replays it on the X-Optable-OID header, so this browser keeps the same identity - where the OPTABLE_OID cookie is blocked. + 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 OPTABLE_OID cookie is HttpOnly and scoped to Domain=optable.co, so - JavaScript can never read it. Everything below comes from the response body. The DCN reads the cookie - before the header, so the header is a fallback and never an override. This page is served from a - different site than the DCN, so the cookie is a third-party cookie here — exactly the situation a - publisher is in. + 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. +

-
OIS state
-
+
+ + + + +
@@ -119,8 +128,8 @@
Forwarded device signals (sig)

- Decoded from the sig param the SDK forwards when forwardSignals: true. The DCN uses - these to derive a fingerprint-based OIS ID for browsers with no usable storage at all. + 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.

@@ -146,143 +155,107 @@ diff --git a/demos/vanilla/ois.html.tpl b/demos/vanilla/ois.html.tpl index 7946d951..f635020c 100644 --- a/demos/vanilla/ois.html.tpl +++ b/demos/vanilla/ois.html.tpl @@ -22,6 +22,8 @@ site: "${DCN_SITE}", node: "${DCN_NODE}", ois: true, + // The DCN derives this identity from these signals, so + // without them there is nothing to derive it from. forwardSignals: true, }); }); @@ -45,27 +47,19 @@ table.ois th { padding: 4px 12px 4px 0; font-size: 0.85rem; - } - .badge { - display: inline-block; - padding: 1px 8px; - border-radius: 10px; - font-size: 0.75rem; - background: #e1e1e1; - } - .badge.cookie { - background: #d3ecd3; - } - .badge.localstorage { - background: #ffe7c2; - } - .badge.unknown { - background: #e1e1e1; + vertical-align: top; } .note { font-size: 0.8rem; color: #666; } + .warn { + font-size: 0.8rem; + background: #fff6e0; + border: 1px solid #f0dca8; + border-radius: 4px; + padding: 0.6rem 1rem; + } @@ -81,36 +75,51 @@

Example: Optable Identity System (OIS) using cookies

- The DCN assigns this browser an OIS ID and returns it as ois_id, alongside - oid_source naming the transport it was resolved from. With ois: true the SDK stores - that ID and replays it on the X-Optable-OID header, so this browser keeps the same identity - where the OPTABLE_OID cookie is blocked. + 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 OPTABLE_OID cookie is HttpOnly and scoped to Domain=optable.co, so - JavaScript can never read it. Everything below comes from the response body. The DCN reads the cookie - before the header, so the header is a fallback and never an override. This page is served from a - different site than the DCN, so the cookie is a third-party cookie here — exactly the situation a - publisher is in. + 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. +

-
OIS state
-
+
+ + + + +
@@ -118,8 +127,8 @@
Forwarded device signals (sig)

- Decoded from the sig param the SDK forwards when forwardSignals: true. The DCN uses - these to derive a fingerprint-based OIS ID for browsers with no usable storage at all. + 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.

@@ -145,143 +154,107 @@ diff --git a/lib/core/network.ts b/lib/core/network.ts index b641ce15..207a1183 100644 --- a/lib/core/network.ts +++ b/lib/core/network.ts @@ -2,7 +2,7 @@ import type { ResolvedConfig } from "../config"; import { default as buildInfo } from "../build.json"; import { LocalStorage } from "./storage"; import { deviceSignals } from "./signals"; -import { applyOISResponse, oisHeaderName, oisParamName, oisRequestID } from "./ois"; +import { oisHeaderName, oisRequestID, readOISHeader } from "./ois"; function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): Request { const { host, cookies, insecure } = config; @@ -65,27 +65,21 @@ 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); - } - - // Replay a stored OIS id so the node still recognizes this browser where the - // OPTABLE_OID cookie is blocked. The node reads the cookie first, so this - // never overrides a cookie that did arrive. - // - // The `ois` param is a separate opt-in: the node only returns the id to a - // caller that asks for it. It is set on every request, including the ones that - // do not replay the header, because the first response is what bootstraps the - // stored id. A query param keeps the request CORS-simple, unlike the header. - if (config.ois && config.consent.deviceAccess) { - url.searchParams.set(oisParamName, "1"); + headers.set("X-Forwarded-For", config.mockedIP); + } + // Replay the stored derived OIS id so the node recognizes this browser + // instead of deriving a new id for it. The cookie identity is not involved: + // the browser attaches OPTABLE_OID on its own and its value is not readable + // from here. + if (config.ois && config.consent.deviceAccess) { const oisID = oisRequestID(config, url.pathname); if (oisID) { - requestInit.headers = new Headers(requestInit.headers); - requestInit.headers.set(oisHeaderName, oisID); + headers.set(oisHeaderName, oisID); } } @@ -95,7 +89,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(); @@ -116,11 +111,10 @@ async function fetch(path: string, config: ResolvedConfig, init?: RequestInit delete data.passport; } - // Persist the OIS id the node reported and strip it from the payload, for the - // same reason as the passport above: a targeting response is handed to ad - // servers and written to the targeting cache. - if (config.ois && data && typeof data === "object") { - applyOISResponse(config, data); + // The derived OIS id arrives on a response header rather than in the body, + // so unlike the passport there is nothing to strip out of the payload. + 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 index 6dcc1642..b727b536 100644 --- a/lib/core/ois.test.ts +++ b/lib/core/ois.test.ts @@ -1,11 +1,12 @@ -import { applyOISResponse, getOISState, oisRequestID, parseEnvelope, shouldStore } from "./ois"; +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: "hostmock.com", - site: "site", + host: TEST_HOST, + site: TEST_SITE, cookies: true, ois: true, consent: { deviceAccess: true }, @@ -13,13 +14,25 @@ const baseConfig = { const storageKey = generateOISKeys(baseConfig).write[0]; -function stored(): { id: string; source: string } | null { - const raw = window.localStorage.getItem(storageKey); - return raw ? JSON.parse(raw) : null; +// Every endpoint where the node derives an OIS id, and therefore returns +// the header and accepts a replayed one. +const HEADER_PATHS = ["/identify", "/uid2/token", "/sync", "/profile", "/v2/targeting"]; + +// /config runs during SDK init and /witness records an event without deriving an +// id, so neither returns the header. Sending it there would buy a CORS preflight +// for nothing. +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 seed(id: string, source: string) { - window.localStorage.setItem(storageKey, JSON.stringify({ v: 1, id, source, ts: 1 })); +function stored(): string | null { + return window.localStorage.getItem(storageKey); } beforeEach(() => { @@ -27,247 +40,196 @@ beforeEach(() => { jest.clearAllMocks(); }); -// The write policy is the whole reason the module does not churn the stored id. -// A minted id arrives on every request to an endpoint that does not replay the -// header (notably /config, on every page load), so treating a mint as -// authoritative would replace a good cookie-backed id constantly. -describe("shouldStore", () => { - const existing = { v: 1, id: "existing", source: "cookie" as const, ts: 1 }; +describe("readOISHeader", () => { + it("stores the id the node returned", () => { + readOISHeader(baseConfig, "/identify", withHeader("ois-id-1")); - it("always stores a cookie-sourced id", () => { - expect(shouldStore("cookie", null)).toBe(true); - expect(shouldStore("cookie", existing)).toBe(true); + expect(stored()).toBe("ois-id-1"); + expect(getOISID(baseConfig)).toBe("ois-id-1"); }); - it("never stores a header-sourced id, which is the one just sent", () => { - expect(shouldStore("header", null)).toBe(false); - expect(shouldStore("header", existing)).toBe(false); - }); - - it("only lets a minted id bootstrap an empty slot", () => { - expect(shouldStore("minted", null)).toBe(true); - expect(shouldStore("minted", existing)).toBe(false); - }); -}); - -// A hand-edited or truncated value must read as absent rather than throw on -// every request for the life of the browser profile. -describe("parseEnvelope", () => { - it("reads a well-formed envelope", () => { - expect(parseEnvelope('{"v":1,"id":"abc","source":"cookie","ts":7}')).toEqual({ - v: 1, - id: "abc", - source: "cookie", - ts: 7, - }); - }); + it.each(HEADER_PATHS)("stores on %s", (path) => { + readOISHeader(baseConfig, path, withHeader("ois-id-1")); - it.each([ - ["null input", null], - ["empty string", ""], - ["not json", "not-json"], - ["a bare legacy string", '"just-an-id"'], - ["json without an id", '{"v":1,"source":"cookie"}'], - ["an empty id", '{"v":1,"id":"","source":"cookie"}'], - ])("treats %s as absent", (_label, raw) => { - expect(parseEnvelope(raw as string | null)).toBeNull(); - }); - - it("falls back to minted for an unrecognized source so the id is not trusted as a cookie", () => { - expect(parseEnvelope('{"v":1,"id":"abc","source":"wat","ts":7}')?.source).toBe("minted"); + expect(stored()).toBe("ois-id-1"); }); -}); -describe("applyOISResponse", () => { - it("stores a cookie-sourced id and strips both fields from the payload", () => { - const data: Record = { ois_id: "cookie-id", oid_source: "cookie", audience: [] }; + // The read side is filtered by the same path set as the send side. A node that + // sets the header somewhere it derives no id is not a reason to store one. + it.each(NON_HEADER_PATHS)("ignores a header returned on %s", (path) => { + readOISHeader(baseConfig, path, withHeader("unexpected")); - applyOISResponse(baseConfig, data); - - expect(stored()).toMatchObject({ id: "cookie-id", source: "cookie" }); - expect(data).toEqual({ audience: [] }); + expect(stored()).toBeNull(); }); - it("overwrites a bootstrapped id once the cookie transport reports one", () => { - seed("bootstrap-id", "minted"); + it.each(NON_HEADER_PATHS)("leaves a stored id alone on %s", (path) => { + readOISHeader(baseConfig, "/identify", withHeader("keep-me")); - applyOISResponse(baseConfig, { ois_id: "real-cookie-id", oid_source: "cookie" }); + readOISHeader(baseConfig, path, withHeader("unexpected")); - expect(stored()).toMatchObject({ id: "real-cookie-id", source: "cookie" }); + expect(stored()).toBe("keep-me"); }); - it("bootstraps from a minted id when nothing is stored", () => { - applyOISResponse(baseConfig, { ois_id: "minted-id", oid_source: "minted" }); + // The node hands back the id derived for the current request, not the one the + // client replayed, so drifting signals roll the stored value forward. + // There is no policy to apply and nothing to reconcile. + it("overwrites an existing id", () => { + readOISHeader(baseConfig, "/identify", withHeader("ois-id-1")); + readOISHeader(baseConfig, "/identify", withHeader("ois-id-2")); - expect(stored()).toMatchObject({ id: "minted-id", source: "minted" }); + expect(stored()).toBe("ois-id-2"); }); - // Without this guard every /config call would replace the id, so the browser - // would present a new identity on each page load. - it("does not let a minted id replace a stored one", () => { - seed("keep-me", "cookie"); + // The node omits the header on endpoints that derive no id, and on requests it + // declines to derive for (non-residential IP, or ID derivation switched off). + // None of those mean "forget the id you have". + it.each([ + ["absent", undefined], + ["empty", ""], + ])("leaves the stored id alone when the header is %s", (_label, value) => { + readOISHeader(baseConfig, "/identify", withHeader("keep-me")); - applyOISResponse(baseConfig, { ois_id: "throwaway", oid_source: "minted" }); + readOISHeader(baseConfig, "/identify", withHeader(value as string | undefined)); - expect(stored()).toMatchObject({ id: "keep-me" }); + expect(stored()).toBe("keep-me"); }); - it("does not rewrite storage when the cookie reports the id already held", () => { - seed("same-id", "cookie"); + it("does not rewrite storage when the id is unchanged", () => { + readOISHeader(baseConfig, "/identify", withHeader("same-id")); jest.clearAllMocks(); - applyOISResponse(baseConfig, { ois_id: "same-id", oid_source: "cookie" }); + readOISHeader(baseConfig, "/identify", withHeader("same-id")); expect(window.localStorage.setItem).not.toHaveBeenCalled(); }); - // The fields ride on /v2/targeting and /config payloads, which are handed to - // ad servers and written to the targeting cache. - it("strips the fields even when nothing is stored", () => { - const data: Record = { ois_id: "x", oid_source: "header", keywords: ["a"] }; - - applyOISResponse(baseConfig, data); - - expect(data).toEqual({ keywords: ["a"] }); - }); - - it("strips the fields when the source is unusable", () => { - const data: Record = { ois_id: "x", oid_source: "nonsense" }; + it("stores nothing without device access consent", () => { + const config = { ...baseConfig, consent: { deviceAccess: false } } as ResolvedConfig; - applyOISResponse(baseConfig, data); + readOISHeader(config, "/identify", withHeader("no-consent")); - expect(data).toEqual({}); expect(stored()).toBeNull(); }); - it("stores nothing without device access consent", () => { - const config = { ...baseConfig, consent: { deviceAccess: false } } as ResolvedConfig; - const data: Record = { ois_id: "no-consent", oid_source: "cookie" }; + it("dispatches optable-ois:change when the id changes", () => { + const listener = jest.fn(); + window.addEventListener("optable-ois:change", listener); - applyOISResponse(config, data); + readOISHeader(baseConfig, "/identify", withHeader("ois-id-1")); - expect(stored()).toBeNull(); - expect(data).toEqual({}); - expect(getOISState(config).storageWritable).toBe(false); + 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(() => seed("stored-id", "cookie")); + beforeEach(() => window.localStorage.setItem(storageKey, "stored-id")); - it.each(["/identify", "/sync", "/uid2/token", "/v2/targeting", "/witness", "/profile"])("replays on %s", (path) => { + it.each(HEADER_PATHS)("replays on %s", (path) => { expect(oisRequestID(baseConfig, path)).toBe("stored-id"); }); - // A custom header makes the request non-simple, so replaying on /config would - // add a CORS preflight to the SDK init path on every page load. - it.each(["/config", "/v1/resolve", "/v2/tokenize", "/v1beta1/contextual"])("does not replay on %s", (path) => { + 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(); }); }); -// transport is what the demo reports. A mint means the node saw neither a cookie -// nor a header, which happens both on a first visit and when the cookie is -// blocked -- indistinguishable from the browser, so it must not claim either. -describe("getOISState transport", () => { - it.each([ - ["cookie", "cookie"], - ["header", "localstorage"], - ["minted", "unknown"], - ])("reports %s as %s", (source, transport) => { - seed("an-id", source); - expect(getOISState(baseConfig).transport).toBe(transport); - }); +describe("buildRequest", () => { + it.each(HEADER_PATHS)("sends the stored id on %s", (path) => { + window.localStorage.setItem(storageKey, "send-me"); - it("reports unknown with nothing stored", () => { - expect(getOISState(baseConfig)).toMatchObject({ id: null, source: null, transport: "unknown" }); - }); + const request = buildRequest(path, baseConfig, { method: "POST" }); - it("exposes the storage key in use", () => { - expect(getOISState(baseConfig).storageKey).toBe(storageKey); - expect(storageKey).toContain("OPTABLE_OIS_"); + expect(request.headers.get(oisHeaderName)).toBe("send-me"); }); -}); -describe("buildRequest OIS opt-in param", () => { - // The DCN returns the id only to a caller that asks for it, and the first - // response is what bootstraps storage — so the param must be sent even on - // endpoints that do not replay the header, and before anything is stored. - it.each(["/config", "/identify", "/v2/targeting"])("opts in on %s", (path) => { - const request = buildRequest(path, baseConfig, { method: "GET" }); + it.each(NON_HEADER_PATHS)("does not send on %s", (path) => { + window.localStorage.setItem(storageKey, "send-me"); - expect(new URL(request.url).searchParams.get("ois")).toBe("1"); - }); - - it("opts in with nothing stored yet, so the id can bootstrap", () => { - const request = buildRequest("/config", baseConfig, { method: "GET" }); + const request = buildRequest(path, baseConfig, { method: "GET" }); - expect(new URL(request.url).searchParams.get("ois")).toBe("1"); - expect(request.headers.get("X-Optable-OID")).toBeNull(); + expect(request.headers.get(oisHeaderName)).toBeNull(); }); - it("does not opt in unless enabled", () => { - const config = { ...baseConfig, ois: undefined } as unknown as ResolvedConfig; + 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(new URL(request.url).searchParams.has("ois")).toBe(false); + expect(request.headers.get(oisHeaderName)).toBeNull(); }); - it("does not opt in without device access consent", () => { - const config = { ...baseConfig, consent: { deviceAccess: false } } as ResolvedConfig; + // The id travels on a header in both directions; there is no query-param + // counterpart, and adding one would break the exact-URL assertions elsewhere. + it("never adds an ois query param", () => { + window.localStorage.setItem(storageKey, "send-me"); - const request = buildRequest("/identify", config, { method: "POST" }); + const request = buildRequest("/identify", baseConfig, { method: "POST" }); expect(new URL(request.url).searchParams.has("ois")).toBe(false); }); -}); -describe("buildRequest OIS header", () => { - it("sends a stored id on a replay path", () => { - seed("send-me", "cookie"); + it("preserves headers the caller supplied", () => { + window.localStorage.setItem(storageKey, "send-me"); - const request = buildRequest("/identify", baseConfig, { method: "POST" }); + const request = buildRequest("/identify", baseConfig, { + method: "POST", + headers: { Accept: "application/json" }, + }); - expect(request.headers.get("X-Optable-OID")).toBe("send-me"); + expect(request.headers.get("Accept")).toBe("application/json"); + expect(request.headers.get(oisHeaderName)).toBe("send-me"); }); +}); - it("does not send on /config", () => { - seed("send-me", "cookie"); +// The round trip is the actual contract: an id returned on one response is +// replayed on the next request. +// +// Note this cannot catch the real-world CORS dependency — jsdom does not enforce +// Access-Control-Expose-Headers, so the header is readable here even though a +// browser needs the node to expose it explicitly. +describe("round trip", () => { + it("replays an id received on a response", () => { + readOISHeader(baseConfig, "/identify", withHeader("round-trip-id")); - const request = buildRequest("/config", baseConfig, { method: "GET" }); + const request = buildRequest("/v2/targeting", baseConfig, { method: "GET" }); - expect(request.headers.get("X-Optable-OID")).toBeNull(); + expect(request.headers.get(oisHeaderName)).toBe("round-trip-id"); }); +}); - it("does not send unless opted in", () => { - seed("send-me", "cookie"); - const config = { ...baseConfig, ois: undefined } as unknown as ResolvedConfig; - - const request = buildRequest("/identify", config, { method: "POST" }); +describe("getOISState", () => { + it("reports the stored id and its key", () => { + window.localStorage.setItem(storageKey, "an-id"); - expect(request.headers.get("X-Optable-OID")).toBeNull(); + expect(getOISState(baseConfig)).toEqual({ id: "an-id", storageKey }); + expect(storageKey).toContain("OPTABLE_OIS_"); }); - it("does not send without device access consent", () => { - seed("send-me", "cookie"); - const config = { ...baseConfig, consent: { deviceAccess: false } } as ResolvedConfig; - - const request = buildRequest("/identify", config, { method: "POST" }); - - expect(request.headers.get("X-Optable-OID")).toBeNull(); + it("reports a null id when nothing is stored", () => { + expect(getOISState(baseConfig)).toEqual({ id: null, storageKey }); }); +}); - it("still resolves the path when a query string is already present", () => { - seed("send-me", "cookie"); +describe("clearOISID", () => { + it("forgets the stored id", () => { + window.localStorage.setItem(storageKey, "forget-me"); - const request = buildRequest("/v2/targeting?id=c%3Aabc", baseConfig, { method: "GET" }); + clearOISID(baseConfig); - expect(request.headers.get("X-Optable-OID")).toBe("send-me"); + expect(stored()).toBeNull(); + expect(getOISID(baseConfig)).toBeNull(); }); }); diff --git a/lib/core/ois.ts b/lib/core/ois.ts index 21bee5f6..f439d034 100644 --- a/lib/core/ois.ts +++ b/lib/core/ois.ts @@ -1,256 +1,114 @@ -// The OIS id identifies a browser to the Optable Identity System. The edge -// normally carries it in the OPTABLE_OID cookie, but that cookie is third-party -// (Domain=optable.co, SameSite=None) so it is dropped wherever cross-site -// cookies are blocked. There the edge mints a throwaway id on every request and -// the browser is unrecognizable between calls. +// The SDK's half of the Optable Identity System. // -// This module keeps the id the edge reports in localStorage and replays it on -// the X-Optable-OID header, so a browser keeps one identity when the cookie is -// unavailable. The edge reads the cookie before the header, so the header is a -// fallback and never an override. +// A node recognizes a browser two ways, and only the second needs anything from +// here. The cookie identity in OPTABLE_OID rides along on its own and is HttpOnly, +// so it could not be read even if it did. The derived identity is the one we +// hold: the node derives it from the `sig` signals and returns it on +// X-Optable-OID, and without a stored copy every visit looks like a new device. // -// The cookie is HttpOnly, so the id can never be read from document.cookie. The -// only way a browser learns the id it was assigned is the ois_id response field. +// See the OIS section of README.md for the integrator-facing description. import type { ResolvedConfig } from "../config"; import { LocalStorage } from "./storage"; import { generateOISKeys } from "./storage-keys"; -// Carries a stored id back to the edge. Already allowed by the edge CORS policy. +// Carries the derived OIS id in both directions. Readable on the response only +// because the node lists it in Access-Control-Expose-Headers. const oisHeaderName = "X-Optable-OID"; -// Opts into receiving the resolved OIS id in the response body. The edge returns -// the id only to a caller that asks for it, because it is a stable cross-site -// identifier — the same reason the passport is echoed in-band only when the -// client selected query-string transport. -const oisParamName = "ois"; - -// Dispatched on window whenever the stored id changes, so a page can react -// without polling. Mirrors the targeting cache-refresh event. +// Not exported, matching the targeting change event in ./events/cache-refresh.ts: +// consumers listen for the literal name. const oisChangeEventName = "optable-ois:change"; -const envelopeVersion = 1; - -// The transport the edge resolved an id from, as reported in oid_source. -type OISIDSource = "cookie" | "header" | "minted"; +// The endpoints where the node derives an OIS id, and therefore the only +// ones that carry the header in either direction. A custom header makes a +// request non-simple, so sending it anywhere else buys a CORS preflight for +// nothing — hence no /config, which also means the id does not arrive until the +// first identify, targeting or profile call. /witness is absent too: it records +// an event without deriving an id. +const HEADER_PATHS = new Set(["/identify", "/uid2/token", "/sync", "/profile", "/v2/targeting"]); -// What a browser can conclude about which transport is carrying its id. -type OISTransport = "cookie" | "localstorage" | "unknown"; - -type OISEnvelope = { - v: number; - id: string; - // The source reported when this id was stored, not necessarily the source of - // the most recent request. - source: OISIDSource; - ts: number; -}; +function derivesOISID(pathname: string): boolean { + return HEADER_PATHS.has(pathname); +} type OISState = { id: string | null; - source: OISIDSource | null; - transport: OISTransport; storageKey: string; - // Whether the last write attempt reached localStorage: null until one has - // been attempted. Reported rather than probed, because probing would itself - // write to storage and there is no consent to do so just to answer this. - storageWritable: boolean | null; - updatedAt: number | null; }; -// Endpoints that replay the header. Restricted to the endpoints where the edge -// attaches the OIS id to the event it records, because a custom header makes a -// request non-simple and forces a CORS preflight. -// -// /config is deliberately absent: it runs on the SDK init path, so a preflight -// there is paid on every page load, and the edge does not record an event for -// it. Skipping it means the edge mints a throwaway id for /config on a browser -// with no cookie, which is why a minted id never overwrites a stored one. -const REPLAY_PATHS = new Set(["/identify", "/sync", "/uid2/token", "/v2/targeting", "/witness", "/profile"]); - -function isReplayPath(pathname: string): boolean { - return REPLAY_PATHS.has(pathname); -} - -function isSource(value: unknown): value is OISIDSource { - return value === "cookie" || value === "header" || value === "minted"; +function getOISID(config: ResolvedConfig): string | null { + return new LocalStorage(config).getOIS(); } -// A stored value is only usable if it round-trips to an envelope with an id. A -// malformed or hand-edited value is treated as absent rather than throwing. -function parseEnvelope(raw: string | null): OISEnvelope | null { - if (!raw) { - return null; - } - - try { - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed.id !== "string" || !parsed.id) { - return null; - } - return { - v: typeof parsed.v === "number" ? parsed.v : envelopeVersion, - id: parsed.id, - source: isSource(parsed.source) ? parsed.source : "minted", - ts: typeof parsed.ts === "number" ? parsed.ts : 0, - }; - } catch { - return null; +// Stores the id from this response, replacing any previous one. The node always +// returns the derivation for the current request, so there is no policy to apply +// and drifting signals just roll the stored value forward. +// +// 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 (a +// non-residential IP, or OIS ID derivation switched off for the node). +function readOISHeader(config: ResolvedConfig, pathname: string, headers: Headers): void { + if (!derivesOISID(pathname)) { + return; } -} -function readEnvelope(config: ResolvedConfig): OISEnvelope | null { - return parseEnvelope(new LocalStorage(config).getOIS()); -} - -// Whether the last write attempt reached localStorage. Module-level so that -// state can report it without writing a probe of its own. -let lastWriteOK: boolean | null = null; - -// Reports whether the write landed. LocalStorage throws when storage is full or -// blocked (Safari private mode), and a failed write must not break the request. -function writeEnvelope(config: ResolvedConfig, envelope: OISEnvelope): boolean { - // Without device access consent LocalStorageProxy silently discards the - // write, so treat it as a failure rather than reporting a phantom success. + // Without device access consent LocalStorageProxy discards the write silently, + // so bail before firing a change event that reports nothing changed. if (!config.consent.deviceAccess) { - lastWriteOK = false; - return false; - } - - try { - new LocalStorage(config).setOIS(JSON.stringify(envelope)); - lastWriteOK = true; - } catch { - lastWriteOK = false; - } - - return lastWriteOK; -} - -// Decides whether an id the edge just reported replaces the stored one. -// -// A cookie-sourced id is authoritative: storing it is what lets the same -// identity survive the cookie being blocked later, which is the whole point of -// the module. A header-sourced id is the one just sent, so there is nothing new -// to record. A minted id means neither transport carried an id, which happens on -// a first visit and on every request to an endpoint that does not replay the -// header, so it may only bootstrap an empty slot -- overwriting on a mint would -// churn the id on every page load. -function shouldStore(incoming: OISIDSource, stored: OISEnvelope | null): boolean { - switch (incoming) { - case "cookie": - return true; - case "header": - return false; - case "minted": - return stored === null; + return; } -} - -type OISResponseFields = { - ois_id?: unknown; - oid_source?: unknown; -}; - -// Consumes the ois_id/oid_source fields from a response body, applying them to -// storage and then removing them. -// -// The fields are deleted for the same reason the passport is: a /v2/targeting or -// /config payload is handed to ad servers and written to the targeting cache, so -// anything left on it leaks. -function applyOISResponse(config: ResolvedConfig, data: OISResponseFields): void { - const id = data.ois_id; - const source = data.oid_source; - - delete data.ois_id; - delete data.oid_source; - if (typeof id !== "string" || !id || !isSource(source)) { + const id = headers.get(oisHeaderName); + if (!id) { return; } - const stored = readEnvelope(config); - if (!shouldStore(source, stored)) { + const storage = new LocalStorage(config); + if (storage.getOIS() === id) { return; } - // Storing an identical id would rewrite the same value on every request for - // the whole of a cookie-backed session. - if (stored && stored.id === id && stored.source === source) { + try { + storage.setOIS(id); + } catch { + // Storage is full or blocked (Safari private mode). A failed write must not + // break the response. return; } - if (writeEnvelope(config, { v: envelopeVersion, id, source, ts: Date.now() })) { - notifyChange(config); - } + notifyChange(config); } -// Returns the id to replay on this request, or null when there is nothing to -// send or the endpoint does not replay. function oisRequestID(config: ResolvedConfig, pathname: string): string | null { - if (!isReplayPath(pathname)) { + if (!derivesOISID(pathname)) { return null; } - return readEnvelope(config)?.id ?? null; + return getOISID(config); } -// A minted id means the edge saw no cookie and no header. On a first visit that -// is expected and says nothing about whether cookies work; on a later visit it -// means the cookie was dropped. The two are indistinguishable from the browser, -// so a mint reports "unknown" rather than guessing. -function transportOf(source: OISIDSource | null): OISTransport { - switch (source) { - case "cookie": - return "cookie"; - case "header": - return "localstorage"; - default: - return "unknown"; - } +function clearOISID(config: ResolvedConfig): void { + new LocalStorage(config).clearOIS(); + notifyChange(config); } function getOISState(config: ResolvedConfig): OISState { - const envelope = readEnvelope(config); - return { - id: envelope?.id ?? null, - source: envelope?.source ?? null, - transport: transportOf(envelope?.source ?? null), + id: getOISID(config), storageKey: generateOISKeys(config).write[0], - storageWritable: lastWriteOK, - updatedAt: envelope?.ts ?? null, }; } -function getOISID(config: ResolvedConfig): string | null { - return readEnvelope(config)?.id ?? null; -} - -function clearOISID(config: ResolvedConfig): void { - new LocalStorage(config).clearOIS(); - notifyChange(config); -} - function notifyChange(config: ResolvedConfig): void { - try { - window.dispatchEvent(new CustomEvent(oisChangeEventName, { detail: getOISState(config) })); - } catch { - // A missing CustomEvent constructor must not break a request. - } + // `instance` mirrors the targeting change event, so a page running several SDK + // instances can tell which node fired. + window.dispatchEvent( + new CustomEvent(oisChangeEventName, { + detail: { instance: config.node || config.host, ...getOISState(config) }, + }) + ); } -export { - oisHeaderName, - oisParamName, - oisChangeEventName, - applyOISResponse, - oisRequestID, - getOISState, - getOISID, - clearOISID, - shouldStore, - parseEnvelope, - isReplayPath, -}; -export type { OISState, OISEnvelope, OISIDSource, OISTransport }; +export { oisHeaderName, readOISHeader, oisRequestID, getOISID, getOISState, clearOISID }; +export type { OISState }; diff --git a/lib/core/storage.ts b/lib/core/storage.ts index 054e1410..1bcc31f1 100644 --- a/lib/core/storage.ts +++ b/lib/core/storage.ts @@ -18,18 +18,25 @@ class LocalStorage { private targetingKeys: StorageKeys; private siteKeys: StorageKeys; private pairKeys: StorageKeys; - private oisKeys: StorageKeys; private storage: LocalStorageProxy; + // Derived on demand rather than in the constructor: OIS is opt-in, and every + // key set costs a base64 encode that non-OIS callers would pay on each of the + // many LocalStorage instances the SDK builds. + private oisKeysCache?: StorageKeys; + constructor(private config: ResolvedConfig) { this.passportKeys = generatePassportKeys(config); this.targetingKeys = generateTargetingKeys(config); this.siteKeys = generateSiteKeys(config); this.pairKeys = generatedPairKeys(); - this.oisKeys = generateOISKeys(config); this.storage = new LocalStorageProxy(this.config.consent); } + private get oisKeys(): StorageKeys { + return (this.oisKeysCache ??= generateOISKeys(this.config)); + } + getPassport(): string | null { return this.readStorageKeys(this.passportKeys); } @@ -58,14 +65,12 @@ class LocalStorage { } } - // The OIS envelope is stored as an opaque string; lib/core/ois.ts owns its - // shape and tolerates a malformed value. getOIS(): string | null { return this.readStorageKeys(this.oisKeys); } - setOIS(envelope: string) { - this.writeToStorageKeys(this.oisKeys, envelope); + setOIS(id: string) { + this.writeToStorageKeys(this.oisKeys, id); } clearOIS() { diff --git a/lib/sdk.ts b/lib/sdk.ts index 7d0cedc0..77f912e4 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -31,6 +31,7 @@ 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 { @@ -42,9 +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; - private oisNullWarned: boolean = false; + // Accessors that can legitimately return null before initialization warn once + // per instance, so a page polling one of them does not flood the console. + private warned = new Set(); constructor(dcn: InitConfig) { this.dcn = getConfig(dcn); @@ -106,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." ); @@ -121,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." ); @@ -132,24 +141,25 @@ class OptableSDK { return value; } - // The OIS id currently stored for this node, or null when the node has not - // reported one yet. Requires the `ois` config option. + // The stored derived OIS id, or null when the node has not returned one + // yet. Requires the `ois` config option. + // + // This is not the cookie identity: OPTABLE_OID is HttpOnly and never readable + // from JavaScript. oisId(): string | null { const value = getOISID(this.dcn); - if (value === null && this.dcn.ois && !this.oisNullWarned) { - this.oisNullWarned = true; - console.warn( - "[Optable] oisId() returned null. The OIS id is cached in localStorage once the DCN returns one. " + - "A call before initialization (await sdk.site() or sdk.targeting()) may return null, and a node that is " + - "not OIS-enabled never returns one." + 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; } - // The stored OIS id plus the transport the node last resolved it from. Useful - // for confirming whether the OPTABLE_OID cookie or the localStorage fallback - // is carrying the id. + // The stored derived OIS id and the localStorage key holding it. oisState(): OISState { return getOISState(this.dcn); } From ff7c536a37e48317996a49f69c018b4294b769d2 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Mon, 31 Aug 2026 14:51:38 -0300 Subject: [PATCH 4/6] simplify --- lib/config.ts | 4 --- lib/core/network.ts | 10 +++---- lib/core/ois.test.ts | 29 +++++--------------- lib/core/ois.ts | 63 ++++++++++++++++---------------------------- lib/core/storage.ts | 11 ++------ lib/sdk.ts | 14 ++++------ 6 files changed, 40 insertions(+), 91 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index 47fef56a..13ac184c 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -71,10 +71,6 @@ type InitConfig = { forwardSignals?: boolean; // Participate in the Optable Identity System (OIS). Opt in; requires an // OIS-enabled node and device access consent. - // - // Persists the OIS id the node reports and replays it on the X-Optable-OID - // header, so a browser keeps one identity where the OPTABLE_OID cookie is - // blocked. The cookie still takes precedence server-side when it arrives. 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. diff --git a/lib/core/network.ts b/lib/core/network.ts index 207a1183..02be9fd3 100644 --- a/lib/core/network.ts +++ b/lib/core/network.ts @@ -72,11 +72,9 @@ function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): headers.set("X-Forwarded-For", config.mockedIP); } - // Replay the stored derived OIS id so the node recognizes this browser - // instead of deriving a new id for it. The cookie identity is not involved: - // the browser attaches OPTABLE_OID on its own and its value is not readable - // from here. - if (config.ois && config.consent.deviceAccess) { + // 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); @@ -111,8 +109,6 @@ async function fetch(path: string, config: ResolvedConfig, init?: RequestInit delete data.passport; } - // The derived OIS id arrives on a response header rather than in the body, - // so unlike the passport there is nothing to strip out of the payload. if (config.ois) { readOISHeader(config, new URL(request.url).pathname, response.headers); } diff --git a/lib/core/ois.test.ts b/lib/core/ois.test.ts index b727b536..1800c6f1 100644 --- a/lib/core/ois.test.ts +++ b/lib/core/ois.test.ts @@ -14,13 +14,10 @@ const baseConfig = { const storageKey = generateOISKeys(baseConfig).write[0]; -// Every endpoint where the node derives an OIS id, and therefore returns -// the header and accepts a replayed one. -const HEADER_PATHS = ["/identify", "/uid2/token", "/sync", "/profile", "/v2/targeting"]; +// Endpoints where the node derives an id, in both directions. +const HEADER_PATHS = ["/identify", "/uid2/token", "/profile", "/v2/targeting"]; -// /config runs during SDK init and /witness records an event without deriving an -// id, so neither returns the header. Sending it there would buy a CORS preflight -// for nothing. +// 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 { @@ -54,8 +51,6 @@ describe("readOISHeader", () => { expect(stored()).toBe("ois-id-1"); }); - // The read side is filtered by the same path set as the send side. A node that - // sets the header somewhere it derives no id is not a reason to store one. it.each(NON_HEADER_PATHS)("ignores a header returned on %s", (path) => { readOISHeader(baseConfig, path, withHeader("unexpected")); @@ -70,9 +65,7 @@ describe("readOISHeader", () => { expect(stored()).toBe("keep-me"); }); - // The node hands back the id derived for the current request, not the one the - // client replayed, so drifting signals roll the stored value forward. - // There is no policy to apply and nothing to reconcile. + // 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")); @@ -80,9 +73,6 @@ describe("readOISHeader", () => { expect(stored()).toBe("ois-id-2"); }); - // The node omits the header on endpoints that derive no id, and on requests it - // declines to derive for (non-residential IP, or ID derivation switched off). - // None of those mean "forget the id you have". it.each([ ["absent", undefined], ["empty", ""], @@ -171,8 +161,7 @@ describe("buildRequest", () => { expect(request.headers.get(oisHeaderName)).toBeNull(); }); - // The id travels on a header in both directions; there is no query-param - // counterpart, and adding one would break the exact-URL assertions elsewhere. + // Guards against reintroducing the abandoned ois=1 param design. it("never adds an ois query param", () => { window.localStorage.setItem(storageKey, "send-me"); @@ -194,12 +183,8 @@ describe("buildRequest", () => { }); }); -// The round trip is the actual contract: an id returned on one response is -// replayed on the next request. -// -// Note this cannot catch the real-world CORS dependency — jsdom does not enforce -// Access-Control-Expose-Headers, so the header is readable here even though a -// browser needs the node to expose it explicitly. +// 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")); diff --git a/lib/core/ois.ts b/lib/core/ois.ts index f439d034..8ce5586e 100644 --- a/lib/core/ois.ts +++ b/lib/core/ois.ts @@ -1,32 +1,21 @@ -// The SDK's half of the Optable Identity System. -// -// A node recognizes a browser two ways, and only the second needs anything from -// here. The cookie identity in OPTABLE_OID rides along on its own and is HttpOnly, -// so it could not be read even if it did. The derived identity is the one we -// hold: the node derives it from the `sig` signals and returns it on -// X-Optable-OID, and without a stored copy every visit looks like a new device. -// -// See the OIS section of README.md for the integrator-facing description. +// 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"; -// Carries the derived OIS id in both directions. Readable on the response only -// because the node lists it in Access-Control-Expose-Headers. +// Readable on the response only because the node lists it in +// Access-Control-Expose-Headers. const oisHeaderName = "X-Optable-OID"; -// Not exported, matching the targeting change event in ./events/cache-refresh.ts: -// consumers listen for the literal name. const oisChangeEventName = "optable-ois:change"; -// The endpoints where the node derives an OIS id, and therefore the only -// ones that carry the header in either direction. A custom header makes a -// request non-simple, so sending it anywhere else buys a CORS preflight for -// nothing — hence no /config, which also means the id does not arrive until the -// first identify, targeting or profile call. /witness is absent too: it records -// an event without deriving an id. -const HEADER_PATHS = new Set(["/identify", "/uid2/token", "/sync", "/profile", "/v2/targeting"]); +// 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); @@ -41,20 +30,19 @@ function getOISID(config: ResolvedConfig): string | null { return new LocalStorage(config).getOIS(); } -// Stores the id from this response, replacing any previous one. The node always -// returns the derivation for the current request, so there is no policy to apply -// and drifting signals just roll the stored value forward. -// +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 (a -// non-residential IP, or OIS ID derivation switched off for the node). +// 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; } - // Without device access consent LocalStorageProxy discards the write silently, - // so bail before firing a change event that reports nothing changed. + // LocalStorageProxy discards the write without consent, so bail before firing + // a change event that reports nothing changed. if (!config.consent.deviceAccess) { return; } @@ -72,16 +60,16 @@ function readOISHeader(config: ResolvedConfig, pathname: string, headers: Header try { storage.setOIS(id); } catch { - // Storage is full or blocked (Safari private mode). A failed write must not + // Storage full or blocked (Safari private mode); a failed write must not // break the response. return; } - notifyChange(config); + notifyChange(config, { id, storageKey: oisStorageKey(config) }); } function oisRequestID(config: ResolvedConfig, pathname: string): string | null { - if (!derivesOISID(pathname)) { + if (!derivesOISID(pathname) || !config.consent.deviceAccess) { return null; } @@ -90,22 +78,17 @@ function oisRequestID(config: ResolvedConfig, pathname: string): string | null { function clearOISID(config: ResolvedConfig): void { new LocalStorage(config).clearOIS(); - notifyChange(config); + notifyChange(config, { id: null, storageKey: oisStorageKey(config) }); } function getOISState(config: ResolvedConfig): OISState { - return { - id: getOISID(config), - storageKey: generateOISKeys(config).write[0], - }; + return { id: getOISID(config), storageKey: oisStorageKey(config) }; } -function notifyChange(config: ResolvedConfig): void { - // `instance` mirrors the targeting change event, so a page running several SDK - // instances can tell which node fired. +function notifyChange(config: ResolvedConfig, state: OISState): void { window.dispatchEvent( new CustomEvent(oisChangeEventName, { - detail: { instance: config.node || config.host, ...getOISState(config) }, + detail: { instance: config.node || config.host, ...state }, }) ); } diff --git a/lib/core/storage.ts b/lib/core/storage.ts index 1bcc31f1..54a920d7 100644 --- a/lib/core/storage.ts +++ b/lib/core/storage.ts @@ -18,25 +18,18 @@ class LocalStorage { private targetingKeys: StorageKeys; private siteKeys: StorageKeys; private pairKeys: StorageKeys; + private oisKeys: StorageKeys; private storage: LocalStorageProxy; - // Derived on demand rather than in the constructor: OIS is opt-in, and every - // key set costs a base64 encode that non-OIS callers would pay on each of the - // many LocalStorage instances the SDK builds. - private oisKeysCache?: StorageKeys; - constructor(private config: ResolvedConfig) { this.passportKeys = generatePassportKeys(config); this.targetingKeys = generateTargetingKeys(config); this.siteKeys = generateSiteKeys(config); this.pairKeys = generatedPairKeys(); + this.oisKeys = generateOISKeys(config); this.storage = new LocalStorageProxy(this.config.consent); } - private get oisKeys(): StorageKeys { - return (this.oisKeysCache ??= generateOISKeys(this.config)); - } - getPassport(): string | null { return this.readStorageKeys(this.passportKeys); } diff --git a/lib/sdk.ts b/lib/sdk.ts index 77f912e4..0985dd85 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -43,8 +43,8 @@ class OptableSDK { private contextSent: boolean = false; private contextConfig: PageContextConfig | null = null; private contextualResponse: ContextualSegmentsResponse | null = null; - // Accessors that can legitimately return null before initialization warn once - // per instance, so a page polling one of them does not flood the console. + // 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) { @@ -141,11 +141,8 @@ class OptableSDK { return value; } - // The stored derived OIS id, or null when the node has not returned one - // yet. Requires the `ois` config option. - // - // This is not the cookie identity: OPTABLE_OID is HttpOnly and never readable - // from JavaScript. + // 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) { @@ -159,12 +156,11 @@ class OptableSDK { return value; } - // The stored derived OIS id and the localStorage key holding it. oisState(): OISState { return getOISState(this.dcn); } - // Forgets the stored OIS id. The node issues a new one on the next call. + // Forgets the stored id; the node issues a new one on the next call. oisClear(): void { clearOISID(this.dcn); } From 88737df620b7c49c6f03b7b40555b78c6d289105 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Wed, 2 Sep 2026 15:08:32 -0400 Subject: [PATCH 5/6] simplify v2 --- README.md | 2 +- lib/core/network.ts | 17 ++++++++++------- lib/core/ois.test.ts | 3 --- lib/core/ois.ts | 32 +++++++++++--------------------- lib/sdk.ts | 4 ---- 5 files changed, 22 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index f80e1486..3f80296c 100644 --- a/README.md +++ b/README.md @@ -1193,7 +1193,7 @@ window.addEventListener("optable-ois:change", (e) => console.log(e.detail)); 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. +Both directions are limited to the endpoints where the DCN derives an identity: `/identify`, `/uid2/token`, `/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. diff --git a/lib/core/network.ts b/lib/core/network.ts index 02be9fd3..dfa92bf0 100644 --- a/lib/core/network.ts +++ b/lib/core/network.ts @@ -72,12 +72,14 @@ function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): 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); + try { + headers.set(oisHeaderName, oisID); + } catch { + // A stored value carrying characters a header cannot hold must not brick every call. + } } } @@ -90,6 +92,11 @@ async function fetch(path: string, config: ResolvedConfig, init?: RequestInit const request = buildRequest(path, config, init); const response = await globalThis.fetch(request); + // Ahead of the error throw below: a non-2xx still carries 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(); @@ -109,10 +116,6 @@ 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 index 1800c6f1..5c1caeda 100644 --- a/lib/core/ois.test.ts +++ b/lib/core/ois.test.ts @@ -14,10 +14,7 @@ const baseConfig = { 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 { diff --git a/lib/core/ois.ts b/lib/core/ois.ts index 8ce5586e..f344c45d 100644 --- a/lib/core/ois.ts +++ b/lib/core/ois.ts @@ -1,20 +1,14 @@ -// 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. +// 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. +// A custom header makes a request non-simple, so sending it where the node derives +// no id buys a CORS preflight for nothing — notably /config, on every page load. const HEADER_PATHS = new Set(["/identify", "/uid2/token", "/profile", "/v2/targeting"]); function derivesOISID(pathname: string): boolean { @@ -34,16 +28,8 @@ 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) { + if (!derivesOISID(pathname) || !config.consent.deviceAccess) { return; } @@ -60,8 +46,7 @@ function readOISHeader(config: ResolvedConfig, pathname: string, headers: Header try { storage.setOIS(id); } catch { - // Storage full or blocked (Safari private mode); a failed write must not - // break the response. + // Storage full or blocked (Safari private mode). return; } @@ -77,7 +62,12 @@ function oisRequestID(config: ResolvedConfig, pathname: string): string | null { } function clearOISID(config: ResolvedConfig): void { - new LocalStorage(config).clearOIS(); + const storage = new LocalStorage(config); + if (storage.getOIS() === null) { + return; + } + + storage.clearOIS(); notifyChange(config, { id: null, storageKey: oisStorageKey(config) }); } diff --git a/lib/sdk.ts b/lib/sdk.ts index 0985dd85..af5a07f5 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -43,8 +43,6 @@ class OptableSDK { private contextSent: boolean = false; private contextConfig: PageContextConfig | null = null; private contextualResponse: ContextualSegmentsResponse | null = null; - // 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) { @@ -141,7 +139,6 @@ 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); @@ -160,7 +157,6 @@ class OptableSDK { return getOISState(this.dcn); } - // Forgets the stored id; the node issues a new one on the next call. oisClear(): void { clearOISID(this.dcn); } From 7d098f948b089e6a80ea7546f1064fbefd63354c Mon Sep 17 00:00:00 2001 From: mosherBT Date: Wed, 2 Sep 2026 15:39:32 -0400 Subject: [PATCH 6/6] remove demos page --- README.md | 6 +- demos/index-nocookies.html | 8 --- demos/index.html | 8 --- demos/vanilla/nocookies/ois.html.tpl | 82 +++++++--------------------- demos/vanilla/ois.html.tpl | 82 +++++++--------------------- lib/core/network.ts | 2 +- lib/core/ois.test.ts | 6 +- lib/core/ois.ts | 7 +-- lib/sdk.ts | 3 +- 9 files changed, 48 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index 3f80296c..cc4e72d5 100644 --- a/README.md +++ b/README.md @@ -1134,7 +1134,7 @@ An OIS-enabled node recognizes a browser two ways, and only one of them involves ### 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. +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. @@ -1187,13 +1187,13 @@ The SDK dispatches an `optable-ois:change` event on `window` whenever the stored 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. +`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`, `/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. +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. diff --git a/demos/index-nocookies.html b/demos/index-nocookies.html index f380ea2f..703635c7 100644 --- a/demos/index-nocookies.html +++ b/demos/index-nocookies.html @@ -174,14 +174,6 @@
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 3e2ea33c..f26c196a 100644 --- a/demos/index.html +++ b/demos/index.html @@ -181,14 +181,6 @@
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 index f9f4bc1e..42f5c427 100644 --- a/demos/vanilla/nocookies/ois.html.tpl +++ b/demos/vanilla/nocookies/ois.html.tpl @@ -46,20 +46,25 @@ } table.ois td, table.ois th { - padding: 4px 12px 4px 0; - font-size: 0.85rem; + padding: 8px 16px 8px 0; + font-size: 0.95rem; + line-height: 1.5; vertical-align: top; } .note { - font-size: 0.8rem; - color: #666; + font-size: 0.95rem; + line-height: 1.65; + color: #555; + max-width: 46rem; } .warn { - font-size: 0.8rem; + font-size: 0.95rem; + line-height: 1.65; + max-width: 46rem; background: #fff6e0; border: 1px solid #f0dca8; border-radius: 4px; - padding: 0.6rem 1rem; + padding: 0.8rem 1.2rem; } @@ -77,9 +82,7 @@

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. + both.

@@ -88,8 +91,8 @@
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 + 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.

@@ -99,10 +102,9 @@
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 + 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, targeting or profile call — not during - initialization. + identify or profile call — not during initialization.

@@ -117,24 +119,12 @@

-
-
-
-
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
@@ -170,44 +160,17 @@ state.id ? "" + state.id + "" : "none yet", state.id ? "Replayed on X-Optable-OID on the next call." : "Run a call below." ) + row("Storage key", "" + state.storageKey + ""); - - renderSignals(); } - function renderSignals() { - const target = document.getElementById("signals"); - if (!lastSig) { - target.innerHTML = row("sig", "no call made yet"); - return; - } - - let html = row("Encoded", "" + lastSig + ""); - try { - // base64url -> base64 before decoding. - const decoded = atob(lastSig.replace(/-/g, "+").replace(/_/g, "/")); - new URLSearchParams(decoded).forEach(function (value, key) { - html += row(key, "" + value + ""); - }); - } catch (e) { - html += row("decode", "failed"); - } - target.innerHTML = html; - } - - // The SDK does not expose the sig blob or the outgoing header, and the - // received header is only visible on the Response, so wrap fetch to show - // what actually went over the wire rather than re-deriving it here. - let lastSig = ""; + // The outgoing header is not exposed by the SDK and the received one is + // only visible on the Response, so wrap fetch to show what actually went + // over the wire. const nativeFetch = window.fetch; window.fetch = function (input) { let path = ""; try { const url = new URL(input instanceof Request ? input.url : String(input)); path = url.pathname; - const sig = url.searchParams.get("sig"); - if (sig) { - lastSig = sig; - } const sent = input instanceof Request ? input.headers.get("X-Optable-OID") : null; log(path + " -> X-Optable-OID: " + (sent || "(none stored yet)")); } catch (e) { @@ -241,13 +204,6 @@ .catch((err) => log("identify error: " + err.message)); }); - document.getElementById("targeting-button").addEventListener("click", () => { - optable.instance - .targeting() - .then(() => log("targeting ok")) - .catch((err) => log("targeting error: " + err.message)); - }); - document.getElementById("clear-button").addEventListener("click", () => { optable.instance.oisClear(); log("cleared stored ID"); diff --git a/demos/vanilla/ois.html.tpl b/demos/vanilla/ois.html.tpl index f635020c..b0650f0b 100644 --- a/demos/vanilla/ois.html.tpl +++ b/demos/vanilla/ois.html.tpl @@ -45,20 +45,25 @@ } table.ois td, table.ois th { - padding: 4px 12px 4px 0; - font-size: 0.85rem; + padding: 8px 16px 8px 0; + font-size: 0.95rem; + line-height: 1.5; vertical-align: top; } .note { - font-size: 0.8rem; - color: #666; + font-size: 0.95rem; + line-height: 1.65; + color: #555; + max-width: 46rem; } .warn { - font-size: 0.8rem; + font-size: 0.95rem; + line-height: 1.65; + max-width: 46rem; background: #fff6e0; border: 1px solid #f0dca8; border-radius: 4px; - padding: 0.6rem 1rem; + padding: 0.8rem 1.2rem; } @@ -76,9 +81,7 @@

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. + both.

@@ -87,8 +90,8 @@
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 + 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.

@@ -98,10 +101,9 @@
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 + 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, targeting or profile call — not during - initialization. + identify or profile call — not during initialization.

@@ -116,24 +118,12 @@

-
-
-
-
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
@@ -169,44 +159,17 @@ state.id ? "" + state.id + "" : "none yet", state.id ? "Replayed on X-Optable-OID on the next call." : "Run a call below." ) + row("Storage key", "" + state.storageKey + ""); - - renderSignals(); } - function renderSignals() { - const target = document.getElementById("signals"); - if (!lastSig) { - target.innerHTML = row("sig", "no call made yet"); - return; - } - - let html = row("Encoded", "" + lastSig + ""); - try { - // base64url -> base64 before decoding. - const decoded = atob(lastSig.replace(/-/g, "+").replace(/_/g, "/")); - new URLSearchParams(decoded).forEach(function (value, key) { - html += row(key, "" + value + ""); - }); - } catch (e) { - html += row("decode", "failed"); - } - target.innerHTML = html; - } - - // The SDK does not expose the sig blob or the outgoing header, and the - // received header is only visible on the Response, so wrap fetch to show - // what actually went over the wire rather than re-deriving it here. - let lastSig = ""; + // The outgoing header is not exposed by the SDK and the received one is + // only visible on the Response, so wrap fetch to show what actually went + // over the wire. const nativeFetch = window.fetch; window.fetch = function (input) { let path = ""; try { const url = new URL(input instanceof Request ? input.url : String(input)); path = url.pathname; - const sig = url.searchParams.get("sig"); - if (sig) { - lastSig = sig; - } const sent = input instanceof Request ? input.headers.get("X-Optable-OID") : null; log(path + " -> X-Optable-OID: " + (sent || "(none stored yet)")); } catch (e) { @@ -240,13 +203,6 @@ .catch((err) => log("identify error: " + err.message)); }); - document.getElementById("targeting-button").addEventListener("click", () => { - optable.instance - .targeting() - .then(() => log("targeting ok")) - .catch((err) => log("targeting error: " + err.message)); - }); - document.getElementById("clear-button").addEventListener("click", () => { optable.instance.oisClear(); log("cleared stored ID"); diff --git a/lib/core/network.ts b/lib/core/network.ts index dfa92bf0..39225c75 100644 --- a/lib/core/network.ts +++ b/lib/core/network.ts @@ -92,7 +92,7 @@ async function fetch(path: string, config: ResolvedConfig, init?: RequestInit const request = buildRequest(path, config, init); const response = await globalThis.fetch(request); - // Ahead of the error throw below: a non-2xx still carries a derived id. + // 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); } diff --git a/lib/core/ois.test.ts b/lib/core/ois.test.ts index 5c1caeda..bbb13282 100644 --- a/lib/core/ois.test.ts +++ b/lib/core/ois.test.ts @@ -14,8 +14,8 @@ const baseConfig = { const storageKey = generateOISKeys(baseConfig).write[0]; -const HEADER_PATHS = ["/identify", "/uid2/token", "/profile", "/v2/targeting"]; -const NON_HEADER_PATHS = ["/config", "/witness", "/targeting", "/v1/resolve", "/v2/tokenize"]; +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(); @@ -186,7 +186,7 @@ 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" }); + const request = buildRequest("/profile", baseConfig, { method: "POST" }); expect(request.headers.get(oisHeaderName)).toBe("round-trip-id"); }); diff --git a/lib/core/ois.ts b/lib/core/ois.ts index f344c45d..86305825 100644 --- a/lib/core/ois.ts +++ b/lib/core/ois.ts @@ -2,14 +2,11 @@ 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"; -// A custom header makes a request non-simple, so sending it where the node derives -// no id buys a CORS preflight for nothing — notably /config, on every page load. -const HEADER_PATHS = new Set(["/identify", "/uid2/token", "/profile", "/v2/targeting"]); +const HEADER_PATHS = new Set(["/identify", "/uid2/token", "/profile"]); function derivesOISID(pathname: string): boolean { return HEADER_PATHS.has(pathname); @@ -46,7 +43,7 @@ function readOISHeader(config: ResolvedConfig, pathname: string, headers: Header try { storage.setOIS(id); } catch { - // Storage full or blocked (Safari private mode). + // Storage full or blocked. return; } diff --git a/lib/sdk.ts b/lib/sdk.ts index af5a07f5..a70dc838 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -139,14 +139,13 @@ class OptableSDK { return value; } - // 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 " + + "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." ); }