diff --git a/README.md b/README.md index 665acbd..fa64833 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Optable Web SDK [![Continuous Integration](https://github.com/Optable/optable-web-sdk/actions/workflows/pull-request.yml/badge.svg)](https://github.com/Optable/optable-web-sdk/actions/workflows/pull-request.yml) +# Optable Web SDK [![Continuous Integration](https://github.com/Optable/optable-web-sdk/actions/workflows/pull-request.yml/badge.svg)](https://github.com/Optable/optable-web-sdk/actions/workflows/pull-request.yml) JavaScript SDK for integrating with an [Optable Data Connectivity Node (DCN)](https://docs.optable.co/) from a web site or web application. -## Contents +## Contents - [Installing](#installing) - [NPM module](#npm-module) @@ -11,11 +11,22 @@ JavaScript SDK for integrating with an [Optable Data Connectivity Node (DCN)](ht - [Domains and Cookies](#domains-and-cookies) - [LocalStorage](#localstorage) - [Using the NPM module](#using-the-npm-module) +- [Initialization Configuration (`InitConfig`)](#initialization-configuration-initconfig) + - [Required Keys](#required-keys) + - [Optional Keys](#optional-keys) +- [Usage Example](#usage-example) + - [Security \& Privacy](#security--privacy) - [Identify API](#identify-api) - [Profile API](#profile-api) - [Targeting API](#targeting-api) + - [Single Identifier (Default)](#single-identifier-default) + - [Multiple Identifiers](#multiple-identifiers) + - [TypeScript Types](#typescript-types) + - [Caching Targeting Data](#caching-targeting-data) - [Witness API](#witness-api) - [Using a script tag](#using-a-script-tag) + - [Option 1: Automatic Initialization](#option-1-automatic-initialization) + - [Option 2: Manual Initialization with Commands Queue](#option-2-manual-initialization-with-commands-queue) - [Integrating PrebidJS analytics](#integrating-prebidjs-analytics) - [Script tag](#script-tag-1) - [NPM package](#npm-package) @@ -23,14 +34,20 @@ JavaScript SDK for integrating with an [Optable Data Connectivity Node (DCN)](ht - [Targeting key values](#targeting-key-values) - [Targeting key values from local cache](#targeting-key-values-from-local-cache) - [Witnessing ad events](#witnessing-ad-events) - - [Passing Secure Signals to GAM](#gam-secure-signals) + - [GAM Secure Signals](#gam-secure-signals) - [Integrating Prebid](#integrating-prebid) + - [Open Pair ID Prebid Module](#open-pair-id-prebid-module) - [Seller Defined Audiences](#seller-defined-audiences) - [Custom key values](#custom-key-values) - [Identifying visitors arriving from Email newsletters](#identifying-visitors-arriving-from-email-newsletters) - [Insert oeid into your Email newsletter template](#insert-oeid-into-your-email-newsletter-template) - [Call tryIdentifyFromParams SDK API](#call-tryidentifyfromparams-sdk-api) -- [Fetching Google Privacy Sandbox Topics](#fetching-google-privacy-sandbox-topics) +- [Passport and Visitor ID](#passport-and-visitor-id) +- [Multi-Node Targeting Resolver](#multi-node-targeting-resolver) + - [Usage](#usage) + - [Rules](#rules) + - [Return Value](#return-value) + - [Input Type](#input-type) - [Demo Pages](#demo-pages) ## Installing @@ -908,51 +925,28 @@ For example: ``` -## Fetching Google Privacy Sandbox topics +## Passport and Visitor ID -To fetch Google Privacy Sandbox topics using the Optable SDK, you can use the `getTopics` method. This method asynchronously retrieves topics IDs and taxonomy versions from the Chrome browser. Alternatively, you can use the `ingestTopics` method. This method invokes `getTopics` and sends the retrieved topics to the Optable DCN under the trait "topics_api". See the [Topics API dictionary](https://patcg-individual-drafts.github.io/topics/#dictdef-browsingtopic) for details. +The Optable DCN issues a _passport_ (a signed JWT) that is cached in browser `localStorage`. The passport encodes a unique _visitor ID_ that the DCN uses to anonymously identify the browser. Both values can be read synchronously from the SDK: -It is recommended to call this method before making ad calls to ensure that the latest topics are available for targeting. - -```html - - - +```javascript +const passport = sdk.passport(); // string | null — the raw JWT as stored in localStorage +const visitorId = sdk.visitorId(); // string | null — the `id` claim decoded from the passport ``` -## Demo Pages - -The demo pages are working examples of both `identify` and `targeting` APIs, as well as an integration with the [Google Ad Manager 360](https://admanager.google.com/home/) ad server, enabling the targeting of ads served by GAM360 to audiences activated in the [Optable](https://optable.co/) DCN. - -You can browse a recent (but not necessarily the latest) released version of the demo pages at [https://demo.optable.co/](https://demo.optable.co/). The source code to the demos can be found in the [demos directory](https://github.com/Optable/optable-web-sdk/tree/master/demos). The demo pages will connect to the [Optable](https://optable.co/) demo DCN at `sandbox.optable.co` and reference the web site slug `web-sdk-demo`. The GAM360 targeting demo loads ads from a GAM360 account operated by [Optable](https://optable.co/). - -Note that the demo pages at [https://demo.optable.co/](https://demo.optable.co/) will by default rely on secure HTTP first-party cookies as described in [this section](https://github.com/Optable/optable-web-sdk#domains-and-cookies). To see an example based on [LocalStorage](https://github.com/Optable/optable-web-sdk#localstorage), see the [index-nocookies variant here](https://demo.optable.co/index-nocookies.html). - -To build and run the demos locally, you will need [Docker](https://www.docker.com/), `docker-compose` and `make`: - -```shell -cd path/to/optable-web-sdk -make -docker-compose up -``` +Both methods return `null` until the passport has been populated in `localStorage`. By default (`initPassport: true`) the SDK triggers a `/config` call at construction time, and the DCN response populates the passport. -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). +If the returned value is `null`, the SDK logs a one-time warning per instance to help diagnose the cause. The two expected reasons for a `null` return are: -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. +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. ## Multi-Node Targeting Resolver Resolves multiple **Node Targeting Rules** based on **priority** or **aggregation**. This function is available under `window.optable.utils` as part of a collection of helper methods extending the SDK. -### **Usage** +### Usage Define targeting rules: @@ -989,13 +983,13 @@ const result = await window.optable.utils.resolveMultiNodeTargeting(rules); console.log(result); ``` -### **Rules** +### Rules - If **any rule has a `priority`**, the function will return the response with the highest priority (1 being the highest). Lower priorities (2, 3, etc.) are considered progressively less important. Any rules with priority values of 0 or below are ignored. - If **multiple nodes share the highest priority**, merges their `eids`. - If **no priority is set**, aggregates all responses. -### **Return Value** +### Return Value ```typescript type MultiNodeTargetingResponse = { @@ -1006,7 +1000,7 @@ type MultiNodeTargetingResponse = { }; ``` -### **Input Type** +### Input Type ```typescript type NodeTargetingRule = { @@ -1024,3 +1018,23 @@ type NodeTargetingRule = { priority?: number; }; ``` + +## Demo Pages + +The demo pages are working examples of both `identify` and `targeting` APIs, as well as an integration with the [Google Ad Manager 360](https://admanager.google.com/home/) ad server, enabling the targeting of ads served by GAM360 to audiences activated in the [Optable](https://optable.co/) DCN. + +You can browse a recent (but not necessarily the latest) released version of the demo pages at [https://demo.optable.co/](https://demo.optable.co/). The source code to the demos can be found in the [demos directory](https://github.com/Optable/optable-web-sdk/tree/master/demos). The demo pages will connect to the [Optable](https://optable.co/) demo DCN at `sandbox.optable.co` and reference the web site slug `web-sdk-demo`. The GAM360 targeting demo loads ads from a GAM360 account operated by [Optable](https://optable.co/). + +Note that the demo pages at [https://demo.optable.co/](https://demo.optable.co/) will by default rely on secure HTTP first-party cookies as described in [this section](https://github.com/Optable/optable-web-sdk#domains-and-cookies). To see an example based on [LocalStorage](https://github.com/Optable/optable-web-sdk#localstorage), see the [index-nocookies variant here](https://demo.optable.co/index-nocookies.html). + +To build and run the demos locally, you will need [Docker](https://www.docker.com/), `docker-compose` and `make`: + +```shell +cd path/to/optable-web-sdk +make +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. diff --git a/lib/core/storage.test.js b/lib/core/storage.test.js index a1d49c9..a3cf361 100644 --- a/lib/core/storage.test.js +++ b/lib/core/storage.test.js @@ -21,6 +21,48 @@ describe("LocalStorage", () => { expect(store.getPassport()).toBeNull(); }); + describe("getVisitorId", () => { + const jwt = (payload) => "header." + btoa(JSON.stringify(payload)) + ".sig"; + + test("returns null when no passport is stored", () => { + const store = new LocalStorage(randomConfig()); + expect(store.getVisitorId()).toBeNull(); + }); + + test("extracts id claim from the passport JWT payload", () => { + const store = new LocalStorage(randomConfig()); + store.setPassport(jwt({ id: "vid-abc", other: "ignored" })); + expect(store.getVisitorId()).toEqual("vid-abc"); + }); + + test("returns null when passport has no id claim", () => { + const store = new LocalStorage(randomConfig()); + store.setPassport(jwt({ sub: "no-id-here" })); + expect(store.getVisitorId()).toBeNull(); + }); + + test("returns null when passport is not a well-formed JWT", () => { + const store = new LocalStorage(randomConfig()); + store.setPassport("not-a-jwt"); + expect(store.getVisitorId()).toBeNull(); + }); + + test("returns null when passport payload is not valid base64 JSON", () => { + const store = new LocalStorage(randomConfig()); + store.setPassport("header.!!!not-base64!!!.sig"); + expect(store.getVisitorId()).toBeNull(); + }); + + test("decodes base64url-encoded payloads (with - and _ characters)", () => { + const store = new LocalStorage(randomConfig()); + // Hand-crafted payload that produces base64url-specific characters. + const payload = { id: "a?b>cc { const store = new LocalStorage(randomConfig()); expect(store.getTargeting()).toBeNull(); diff --git a/lib/core/storage.ts b/lib/core/storage.ts index 03c6b88..f1b236e 100644 --- a/lib/core/storage.ts +++ b/lib/core/storage.ts @@ -35,6 +35,26 @@ class LocalStorage { this.writeToStorageKeys(this.passportKeys, passport); } + getVisitorId(): string | null { + const passport = this.getPassport(); + if (!passport) return null; + + const payload = passport.split(".")[1]; + if (!payload) return null; + + try { + // JWT payload is base64url; normalize to base64 before atob. + const b64 = payload + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "="); + const claims = JSON.parse(atob(b64)); + return typeof claims?.id === "string" ? claims.id : null; + } catch { + return null; + } + } + getTargeting(): TargetingResponse | null { const raw = this.readStorageKeys(this.targetingKeys); return raw ? JSON.parse(raw) : null; diff --git a/lib/sdk.test.ts b/lib/sdk.test.ts index ce30e0d..5a4153f 100644 --- a/lib/sdk.test.ts +++ b/lib/sdk.test.ts @@ -110,6 +110,16 @@ describe("Breaking change detection: if typescript complains or a test fails it' await new OptableSDK({ ...defaultConfig }).uid2Token("c:a1a335b8216658319f96a4b0c718557ba41dd1f5"); }); + test("TEST SHOULD NEVER NEED TO BE UPDATED, UNLESS MAJOR VERSION UPDATE: passport", () => { + const result = new OptableSDK({ ...defaultConfig }).passport(); + expect(result === null || typeof result === "string").toBe(true); + }); + + test("TEST SHOULD NEVER NEED TO BE UPDATED, UNLESS MAJOR VERSION UPDATE: visitorId", () => { + const result = new OptableSDK({ ...defaultConfig }).visitorId(); + expect(result === null || typeof result === "string").toBe(true); + }); + test("TEST SHOULD NEVER NEED TO BE UPDATED, UNLESS MAJOR VERSION UPDATE: targetingFromCache", async () => { const result = new OptableSDK({ ...defaultConfig }).targetingFromCache(); expect(result).toBeNull(); @@ -520,6 +530,63 @@ describe("behavior testing of", () => { }); }); +describe("passport and visitorId", () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + localStorage.clear(); + warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + test("returns null and warns once when no passport is cached", () => { + const sdk = new OptableSDK({ ...defaultConfig, initPassport: false }); + + expect(sdk.passport()).toBeNull(); + expect(sdk.passport()).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(/\[Optable\] passport\(\) returned null/); + + expect(sdk.visitorId()).toBeNull(); + expect(sdk.visitorId()).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy.mock.calls[1][0]).toMatch(/\[Optable\] visitorId\(\) returned null/); + }); + + test("returns cached passport and decoded visitor id after an edge call populates them", async () => { + const payload = { id: "vid-xyz" }; + const mockJwt = "h." + btoa(JSON.stringify(payload)) + ".s"; + const fetchSpy = jest.spyOn(window, "fetch").mockResolvedValue( + new Response(JSON.stringify({ passport: mockJwt }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const sdk = new OptableSDK({ ...defaultConfig, initPassport: false }); + await sdk.site(); + + expect(sdk.passport()).toEqual(mockJwt); + expect(sdk.visitorId()).toEqual("vid-xyz"); + expect(warnSpy).not.toHaveBeenCalled(); + + fetchSpy.mockRestore(); + }); + + test("warn-once flag is per-instance", () => { + const sdk1 = new OptableSDK({ ...defaultConfig, initPassport: false }); + const sdk2 = new OptableSDK({ ...defaultConfig, initPassport: false }); + + sdk1.passport(); + sdk2.passport(); + + expect(warnSpy).toHaveBeenCalledTimes(2); + }); +}); + describe("normalizeTargetingRequest", () => { test("normalizes string input", () => { const input = "c:123"; diff --git a/lib/sdk.ts b/lib/sdk.ts index e98e929..c4031d4 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -23,6 +23,7 @@ import { Witness } from "./edge/witness"; import { Profile } from "./edge/profile"; import { sha256 } from "js-sha256"; import { Tokenize, TokenizeResponse } from "./edge/tokenize"; +import { LocalStorage } from "./core/storage"; class OptableSDK { public static version = buildInfo.version; @@ -32,6 +33,8 @@ class OptableSDK { private contextSent: boolean = false; private contextConfig: PageContextConfig | null = null; + private passportNullWarned: boolean = false; + private visitorIdNullWarned: boolean = false; constructor(dcn: InitConfig) { this.dcn = getConfig(dcn); @@ -81,6 +84,32 @@ class OptableSDK { return SiteFromCache(this.dcn); } + 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. " + + "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." + ); + } + return value; + } + + 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. " + + "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." + ); + } + return value; + } + targetingClearCache(): void { TargetingClearCache(this.dcn); }