Skip to content

Commit 9f54416

Browse files
authored
Feat add visitorid passport functions (#272)
1 parent 210bd41 commit 9f54416

5 files changed

Lines changed: 212 additions & 40 deletions

File tree

README.md

Lines changed: 54 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
# 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)
1+
# 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 -->
22

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

5-
## Contents
5+
## Contents <!-- omit in toc -->
66

77
- [Installing](#installing)
88
- [NPM module](#npm-module)
@@ -11,26 +11,43 @@ JavaScript SDK for integrating with an [Optable Data Connectivity Node (DCN)](ht
1111
- [Domains and Cookies](#domains-and-cookies)
1212
- [LocalStorage](#localstorage)
1313
- [Using the NPM module](#using-the-npm-module)
14+
- [Initialization Configuration (`InitConfig`)](#initialization-configuration-initconfig)
15+
- [Required Keys](#required-keys)
16+
- [Optional Keys](#optional-keys)
17+
- [Usage Example](#usage-example)
18+
- [Security \& Privacy](#security--privacy)
1419
- [Identify API](#identify-api)
1520
- [Profile API](#profile-api)
1621
- [Targeting API](#targeting-api)
22+
- [Single Identifier (Default)](#single-identifier-default)
23+
- [Multiple Identifiers](#multiple-identifiers)
24+
- [TypeScript Types](#typescript-types)
25+
- [Caching Targeting Data](#caching-targeting-data)
1726
- [Witness API](#witness-api)
1827
- [Using a script tag](#using-a-script-tag)
28+
- [Option 1: Automatic Initialization](#option-1-automatic-initialization)
29+
- [Option 2: Manual Initialization with Commands Queue](#option-2-manual-initialization-with-commands-queue)
1930
- [Integrating PrebidJS analytics](#integrating-prebidjs-analytics)
2031
- [Script tag](#script-tag-1)
2132
- [NPM package](#npm-package)
2233
- [Integrating GAM360](#integrating-gam360)
2334
- [Targeting key values](#targeting-key-values)
2435
- [Targeting key values from local cache](#targeting-key-values-from-local-cache)
2536
- [Witnessing ad events](#witnessing-ad-events)
26-
- [Passing Secure Signals to GAM](#gam-secure-signals)
37+
- [GAM Secure Signals](#gam-secure-signals)
2738
- [Integrating Prebid](#integrating-prebid)
39+
- [Open Pair ID Prebid Module](#open-pair-id-prebid-module)
2840
- [Seller Defined Audiences](#seller-defined-audiences)
2941
- [Custom key values](#custom-key-values)
3042
- [Identifying visitors arriving from Email newsletters](#identifying-visitors-arriving-from-email-newsletters)
3143
- [Insert oeid into your Email newsletter template](#insert-oeid-into-your-email-newsletter-template)
3244
- [Call tryIdentifyFromParams SDK API](#call-tryidentifyfromparams-sdk-api)
33-
- [Fetching Google Privacy Sandbox Topics](#fetching-google-privacy-sandbox-topics)
45+
- [Passport and Visitor ID](#passport-and-visitor-id)
46+
- [Multi-Node Targeting Resolver](#multi-node-targeting-resolver)
47+
- [Usage](#usage)
48+
- [Rules](#rules)
49+
- [Return Value](#return-value)
50+
- [Input Type](#input-type)
3451
- [Demo Pages](#demo-pages)
3552

3653
## Installing
@@ -908,51 +925,28 @@ For example:
908925
</script>
909926
```
910927

911-
## Fetching Google Privacy Sandbox topics
928+
## Passport and Visitor ID
912929

913-
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.
930+
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:
914931

915-
It is recommended to call this method before making ad calls to ensure that the latest topics are available for targeting.
916-
917-
```html
918-
<!-- Optable SDK async load: -->
919-
<script async src="https://cdn.optable.co/web-sdk/latest/sdk.js"></script>
920-
<script>
921-
window.optable = window.optable || { cmd: [] };
922-
optable.cmd.push(function () {
923-
optable.instance = new optable.SDK({ host: "dcn.customer.com", site: "my-site" });
924-
// Fetch Google Privacy Sandbox topics and send them to the Optable DCN
925-
optable.instance.ingestTopics();
926-
});
927-
</script>
932+
```javascript
933+
const passport = sdk.passport(); // string | null — the raw JWT as stored in localStorage
934+
const visitorId = sdk.visitorId(); // string | null — the `id` claim decoded from the passport
928935
```
929936

930-
## Demo Pages
931-
932-
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.
933-
934-
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/).
935-
936-
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).
937-
938-
To build and run the demos locally, you will need [Docker](https://www.docker.com/), `docker-compose` and `make`:
939-
940-
```shell
941-
cd path/to/optable-web-sdk
942-
make
943-
docker-compose up
944-
```
937+
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.
945938

946-
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).
939+
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:
947940

948-
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.
941+
1. The method was called before the passport was cached (e.g. before `sdk.site()` resolved).
942+
2. The DCN is configured to not echo the passport in response bodies, in which case the client-side cache is never populated.
949943

950944
## Multi-Node Targeting Resolver
951945

952946
Resolves multiple **Node Targeting Rules** based on **priority** or **aggregation**.
953947
This function is available under `window.optable.utils` as part of a collection of helper methods extending the SDK.
954948

955-
### **Usage**
949+
### Usage
956950

957951
Define targeting rules:
958952

@@ -989,13 +983,13 @@ const result = await window.optable.utils.resolveMultiNodeTargeting(rules);
989983
console.log(result);
990984
```
991985

992-
### **Rules**
986+
### Rules
993987

994988
- 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.
995989
- If **multiple nodes share the highest priority**, merges their `eids`.
996990
- If **no priority is set**, aggregates all responses.
997991

998-
### **Return Value**
992+
### Return Value
999993

1000994
```typescript
1001995
type MultiNodeTargetingResponse = {
@@ -1006,7 +1000,7 @@ type MultiNodeTargetingResponse = {
10061000
};
10071001
```
10081002

1009-
### **Input Type**
1003+
### Input Type
10101004

10111005
```typescript
10121006
type NodeTargetingRule = {
@@ -1024,3 +1018,23 @@ type NodeTargetingRule = {
10241018
priority?: number;
10251019
};
10261020
```
1021+
1022+
## Demo Pages
1023+
1024+
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.
1025+
1026+
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/).
1027+
1028+
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).
1029+
1030+
To build and run the demos locally, you will need [Docker](https://www.docker.com/), `docker-compose` and `make`:
1031+
1032+
```shell
1033+
cd path/to/optable-web-sdk
1034+
make
1035+
docker-compose up
1036+
```
1037+
1038+
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).
1039+
1040+
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.

lib/core/storage.test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,48 @@ describe("LocalStorage", () => {
2121
expect(store.getPassport()).toBeNull();
2222
});
2323

24+
describe("getVisitorId", () => {
25+
const jwt = (payload) => "header." + btoa(JSON.stringify(payload)) + ".sig";
26+
27+
test("returns null when no passport is stored", () => {
28+
const store = new LocalStorage(randomConfig());
29+
expect(store.getVisitorId()).toBeNull();
30+
});
31+
32+
test("extracts id claim from the passport JWT payload", () => {
33+
const store = new LocalStorage(randomConfig());
34+
store.setPassport(jwt({ id: "vid-abc", other: "ignored" }));
35+
expect(store.getVisitorId()).toEqual("vid-abc");
36+
});
37+
38+
test("returns null when passport has no id claim", () => {
39+
const store = new LocalStorage(randomConfig());
40+
store.setPassport(jwt({ sub: "no-id-here" }));
41+
expect(store.getVisitorId()).toBeNull();
42+
});
43+
44+
test("returns null when passport is not a well-formed JWT", () => {
45+
const store = new LocalStorage(randomConfig());
46+
store.setPassport("not-a-jwt");
47+
expect(store.getVisitorId()).toBeNull();
48+
});
49+
50+
test("returns null when passport payload is not valid base64 JSON", () => {
51+
const store = new LocalStorage(randomConfig());
52+
store.setPassport("header.!!!not-base64!!!.sig");
53+
expect(store.getVisitorId()).toBeNull();
54+
});
55+
56+
test("decodes base64url-encoded payloads (with - and _ characters)", () => {
57+
const store = new LocalStorage(randomConfig());
58+
// Hand-crafted payload that produces base64url-specific characters.
59+
const payload = { id: "a?b>c<d" };
60+
const base64url = btoa(JSON.stringify(payload)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
61+
store.setPassport(`header.${base64url}.sig`);
62+
expect(store.getVisitorId()).toEqual("a?b>c<d");
63+
});
64+
});
65+
2466
test("allows to store and retrieve targeting", () => {
2567
const store = new LocalStorage(randomConfig());
2668
expect(store.getTargeting()).toBeNull();

lib/core/storage.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,26 @@ class LocalStorage {
3535
this.writeToStorageKeys(this.passportKeys, passport);
3636
}
3737

38+
getVisitorId(): string | null {
39+
const passport = this.getPassport();
40+
if (!passport) return null;
41+
42+
const payload = passport.split(".")[1];
43+
if (!payload) return null;
44+
45+
try {
46+
// JWT payload is base64url; normalize to base64 before atob.
47+
const b64 = payload
48+
.replace(/-/g, "+")
49+
.replace(/_/g, "/")
50+
.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "=");
51+
const claims = JSON.parse(atob(b64));
52+
return typeof claims?.id === "string" ? claims.id : null;
53+
} catch {
54+
return null;
55+
}
56+
}
57+
3858
getTargeting(): TargetingResponse | null {
3959
const raw = this.readStorageKeys(this.targetingKeys);
4060
return raw ? JSON.parse(raw) : null;

lib/sdk.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,16 @@ describe("Breaking change detection: if typescript complains or a test fails it'
110110
await new OptableSDK({ ...defaultConfig }).uid2Token("c:a1a335b8216658319f96a4b0c718557ba41dd1f5");
111111
});
112112

113+
test("TEST SHOULD NEVER NEED TO BE UPDATED, UNLESS MAJOR VERSION UPDATE: passport", () => {
114+
const result = new OptableSDK({ ...defaultConfig }).passport();
115+
expect(result === null || typeof result === "string").toBe(true);
116+
});
117+
118+
test("TEST SHOULD NEVER NEED TO BE UPDATED, UNLESS MAJOR VERSION UPDATE: visitorId", () => {
119+
const result = new OptableSDK({ ...defaultConfig }).visitorId();
120+
expect(result === null || typeof result === "string").toBe(true);
121+
});
122+
113123
test("TEST SHOULD NEVER NEED TO BE UPDATED, UNLESS MAJOR VERSION UPDATE: targetingFromCache", async () => {
114124
const result = new OptableSDK({ ...defaultConfig }).targetingFromCache();
115125
expect(result).toBeNull();
@@ -520,6 +530,63 @@ describe("behavior testing of", () => {
520530
});
521531
});
522532

533+
describe("passport and visitorId", () => {
534+
let warnSpy: jest.SpyInstance;
535+
536+
beforeEach(() => {
537+
localStorage.clear();
538+
warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
539+
});
540+
541+
afterEach(() => {
542+
warnSpy.mockRestore();
543+
});
544+
545+
test("returns null and warns once when no passport is cached", () => {
546+
const sdk = new OptableSDK({ ...defaultConfig, initPassport: false });
547+
548+
expect(sdk.passport()).toBeNull();
549+
expect(sdk.passport()).toBeNull();
550+
expect(warnSpy).toHaveBeenCalledTimes(1);
551+
expect(warnSpy.mock.calls[0][0]).toMatch(/\[Optable\] passport\(\) returned null/);
552+
553+
expect(sdk.visitorId()).toBeNull();
554+
expect(sdk.visitorId()).toBeNull();
555+
expect(warnSpy).toHaveBeenCalledTimes(2);
556+
expect(warnSpy.mock.calls[1][0]).toMatch(/\[Optable\] visitorId\(\) returned null/);
557+
});
558+
559+
test("returns cached passport and decoded visitor id after an edge call populates them", async () => {
560+
const payload = { id: "vid-xyz" };
561+
const mockJwt = "h." + btoa(JSON.stringify(payload)) + ".s";
562+
const fetchSpy = jest.spyOn(window, "fetch").mockResolvedValue(
563+
new Response(JSON.stringify({ passport: mockJwt }), {
564+
status: 200,
565+
headers: { "Content-Type": "application/json" },
566+
})
567+
);
568+
569+
const sdk = new OptableSDK({ ...defaultConfig, initPassport: false });
570+
await sdk.site();
571+
572+
expect(sdk.passport()).toEqual(mockJwt);
573+
expect(sdk.visitorId()).toEqual("vid-xyz");
574+
expect(warnSpy).not.toHaveBeenCalled();
575+
576+
fetchSpy.mockRestore();
577+
});
578+
579+
test("warn-once flag is per-instance", () => {
580+
const sdk1 = new OptableSDK({ ...defaultConfig, initPassport: false });
581+
const sdk2 = new OptableSDK({ ...defaultConfig, initPassport: false });
582+
583+
sdk1.passport();
584+
sdk2.passport();
585+
586+
expect(warnSpy).toHaveBeenCalledTimes(2);
587+
});
588+
});
589+
523590
describe("normalizeTargetingRequest", () => {
524591
test("normalizes string input", () => {
525592
const input = "c:123";

lib/sdk.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { Witness } from "./edge/witness";
2323
import { Profile } from "./edge/profile";
2424
import { sha256 } from "js-sha256";
2525
import { Tokenize, TokenizeResponse } from "./edge/tokenize";
26+
import { LocalStorage } from "./core/storage";
2627

2728
class OptableSDK {
2829
public static version = buildInfo.version;
@@ -32,6 +33,8 @@ class OptableSDK {
3233

3334
private contextSent: boolean = false;
3435
private contextConfig: PageContextConfig | null = null;
36+
private passportNullWarned: boolean = false;
37+
private visitorIdNullWarned: boolean = false;
3538

3639
constructor(dcn: InitConfig) {
3740
this.dcn = getConfig(dcn);
@@ -81,6 +84,32 @@ class OptableSDK {
8184
return SiteFromCache(this.dcn);
8285
}
8386

87+
passport(): string | null {
88+
const value = new LocalStorage(this.dcn).getPassport();
89+
if (value === null && !this.passportNullWarned) {
90+
this.passportNullWarned = true;
91+
console.warn(
92+
"[Optable] passport() returned null. The passport is cached in localStorage once the DCN returns one. " +
93+
"Call before initialization (await sdk.site() or sdk.targeting()) may return null, and deployments where the DCN " +
94+
"does not echo the passport in response bodies will never populate it client-side."
95+
);
96+
}
97+
return value;
98+
}
99+
100+
visitorId(): string | null {
101+
const value = new LocalStorage(this.dcn).getVisitorId();
102+
if (value === null && !this.visitorIdNullWarned) {
103+
this.visitorIdNullWarned = true;
104+
console.warn(
105+
"[Optable] visitorId() returned null. The visitor ID is derived from the passport JWT in localStorage. " +
106+
"Call before initialization (await sdk.site() or sdk.targeting()) may return null, and deployments where the DCN " +
107+
"does not echo the passport in response bodies will never populate it client-side."
108+
);
109+
}
110+
return value;
111+
}
112+
84113
targetingClearCache(): void {
85114
TargetingClearCache(this.dcn);
86115
}

0 commit comments

Comments
 (0)