Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 54 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
@@ -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) <!-- omit in toc -->

JavaScript SDK for integrating with an [Optable Data Connectivity Node (DCN)](https://docs.optable.co/) from a web site or web application.

## Contents
## Contents <!-- omit in toc -->

- [Installing](#installing)
- [NPM module](#npm-module)
Expand All @@ -11,26 +11,43 @@ 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)
- [Integrating GAM360](#integrating-gam360)
- [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
Expand Down Expand Up @@ -908,51 +925,28 @@ For example:
</script>
```

## 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
<!-- Optable SDK async load: -->
<script async src="https://cdn.optable.co/web-sdk/latest/sdk.js"></script>
<script>
window.optable = window.optable || { cmd: [] };
optable.cmd.push(function () {
optable.instance = new optable.SDK({ host: "dcn.customer.com", site: "my-site" });
// Fetch Google Privacy Sandbox topics and send them to the Optable DCN
optable.instance.ingestTopics();
});
</script>
```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:

Expand Down Expand Up @@ -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 = {
Expand All @@ -1006,7 +1000,7 @@ type MultiNodeTargetingResponse = {
};
```

### **Input Type**
### Input Type

```typescript
type NodeTargetingRule = {
Expand All @@ -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.
42 changes: 42 additions & 0 deletions lib/core/storage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>c<d" };
const base64url = btoa(JSON.stringify(payload)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
store.setPassport(`header.${base64url}.sig`);
expect(store.getVisitorId()).toEqual("a?b>c<d");
});
});

test("allows to store and retrieve targeting", () => {
const store = new LocalStorage(randomConfig());
expect(store.getTargeting()).toBeNull();
Expand Down
20 changes: 20 additions & 0 deletions lib/core/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
67 changes: 67 additions & 0 deletions lib/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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";
Expand Down
29 changes: 29 additions & 0 deletions lib/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
Loading