diff --git a/.vscode/launch.json b/.vscode/launch.json
index 897bd4ff..36fed78d 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -8,7 +8,7 @@
"name": "Launch CSTG (Chrome)",
"type": "chrome",
"request": "launch",
- "url": "http://localhost:9090",
+ "url": "http://localhost:9190",
"webRoot": "${workspaceRoot}/",
"sourceMaps": true,
"preLaunchTask": "Start CSTG",
@@ -30,7 +30,7 @@
"name": "Launch Secure Signals (Chrome)",
"type": "chrome",
"request": "launch",
- "url": "http://localhost:9090",
+ "url": "http://localhost:9190",
"webRoot": "${workspaceRoot}/",
"sourceMaps": true,
"preLaunchTask": "Start Secure Signals",
diff --git a/.vscode/settings.json b/.vscode/settings.json
index f74b7dee..7b36f739 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,6 +1,7 @@
{
"cSpell.words": [
"cstg",
+ "EUID",
"googletag",
"initialised",
"initialising",
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 6fe2c907..91a11783 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -61,7 +61,7 @@
"containerName": "uid2-cstg-example",
"ports": [
{
- "hostPort": 9090,
+ "hostPort": 9190,
"containerPort": 80
}
],
@@ -75,8 +75,9 @@
"env": {
"UID2_BASE_URL": "http://localhost:8080",
"UID2_JS_SDK_URL": "http://localhost:9091/uid2-sdk.js",
- "SERVER_PUBLIC_KEY": "UID2-X-I-MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEtXJdTSZAYHvoRDWiehMHoWF1BNPuqLs5w2ZHiAZ1IJc7O4/z0ojPTB0V+KYX/wxQK0hxx6kxCvHj335eI/ZQsQ==",
- "SUBSCRIPTION_ID": "4WvryDGbR5"
+ "EUID_JS_SDK_URL": "http://localhost:9091/euid-sdk.js",
+ "SERVER_PUBLIC_KEY": "UID2-X-L-MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWyCP9O/6ppffj8f5PUWsEhAoMNdTBnpnkiOPZBkVnLkxOyTjPsKzf5J3ApPHzutAGNGgKAzFc6TuCfo+BWsZtQ==",
+ "SUBSCRIPTION_ID": "LBk2xJsgrS"
},
"remove": true
}
@@ -118,7 +119,7 @@
"containerName": "uid2-secure-signals-example",
"ports": [
{
- "hostPort": 9090,
+ "hostPort": 9190,
"containerPort": 3000
}
],
@@ -129,7 +130,9 @@
"permissions": "ro"
}
],
- "envFiles": ["${workspaceFolder}/examples/google-secure-signals-integration/with_sdk_v3/.env"],
+ "envFiles": [
+ "${workspaceFolder}/examples/google-secure-signals-integration/with_sdk_v3/.env"
+ ],
"remove": true
}
},
diff --git a/examples/cstg/html/index.html b/examples/cstg/html/index.html
index a5d364c9..dc0b9a53 100644
--- a/examples/cstg/html/index.html
+++ b/examples/cstg/html/index.html
@@ -7,53 +7,96 @@
+
-
{
+ this._callbackManager.runCallbacks(EventType.SdkLoaded, {});
+ };
+ if (window.__euid instanceof EUID) {
+ runCallbacks();
+ } else {
+ // Need to defer running callbacks until this is assigned to the window global
+ callbackContainer.callback = runCallbacks;
+ }
+ }
+}
+
+declare global {
+ interface Window {
+ __euid: EUID | UID2Setup | undefined;
+ }
+}
+
+export function __euidInternalHandleScriptLoad() {
+ const callbacks = window?.__euid?.callbacks || [];
+ const callbackContainer: CallbackContainer = {};
+ window.__euid = new EUID(callbacks, callbackContainer);
+ if (callbackContainer.callback) callbackContainer.callback();
+}
+__euidInternalHandleScriptLoad();
+
+export const sdkWindow = globalThis.window;
diff --git a/src/integrationTests/euidSdk.test.ts b/src/integrationTests/euidSdk.test.ts
new file mode 100644
index 00000000..a79e15c3
--- /dev/null
+++ b/src/integrationTests/euidSdk.test.ts
@@ -0,0 +1,78 @@
+import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals';
+
+import * as mocks from '../mocks';
+import { sdkWindow, EUID, __euidInternalHandleScriptLoad } from '../euidSdk';
+import { EventType, Uid2CallbackHandler } from '../uid2CallbackManager';
+
+let callback: any;
+let asyncCallback: jest.Mock;
+let euid: EUID;
+let xhrMock: any;
+
+const debugOutput = false;
+
+mocks.setupFakeTime();
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mocks.resetFakeTime();
+ jest.runOnlyPendingTimers();
+
+ callback = jest.fn();
+ xhrMock = new mocks.XhrMock(sdkWindow);
+ mocks.setCookieMock(sdkWindow.document);
+ asyncCallback = jest.fn((event, payload) => {
+ if (debugOutput) {
+ console.log('Async Callback Event:', event);
+ console.log('Payload:', payload);
+ }
+ });
+});
+
+afterEach(() => {
+ mocks.resetFakeTime();
+});
+
+const makeIdentity = mocks.makeIdentityV2;
+
+describe('when a callback is provided', () => {
+ const refreshFrom = Date.now() + 100;
+ const identity = { ...makeIdentity(), refresh_from: refreshFrom };
+ const refreshedIdentity = {
+ ...makeIdentity(),
+ advertising_token: 'refreshed_token',
+ };
+ describe('before constructor is called', () => {
+ test('it should be called during the construction process', () => {
+ sdkWindow.__euid = { callbacks: [asyncCallback] };
+ const calls = asyncCallback.mock.calls.length;
+ __euidInternalHandleScriptLoad();
+ expect(asyncCallback).toBeCalledTimes(calls + 1);
+ expect(asyncCallback).toBeCalledWith(EventType.SdkLoaded, expect.anything());
+ });
+ test('it should not be called by the constructor itself', () => {
+ sdkWindow.__euid = { callbacks: [asyncCallback] };
+ const calls = asyncCallback.mock.calls.length;
+ new EUID([asyncCallback]);
+ expect(asyncCallback).toBeCalledTimes(calls);
+ });
+ });
+ describe('before construction but the window global has already been assigned', () => {
+ // N.B. this is an artificial situation to check an edge case.
+ test('it should be called during construction', () => {
+ sdkWindow.__euid = new EUID();
+ const calls = asyncCallback.mock.calls.length;
+ new EUID([asyncCallback]);
+ expect(asyncCallback).toBeCalledTimes(calls + 1);
+ });
+ });
+ describe('after construction', () => {
+ test('the SDKLoaded event is sent immediately', () => {
+ sdkWindow.__euid = new EUID();
+ const calls = asyncCallback.mock.calls.length;
+ sdkWindow.__euid.callbacks!.push(asyncCallback);
+ expect(asyncCallback).toBeCalledTimes(calls + 1);
+ expect(asyncCallback).toBeCalledWith(EventType.SdkLoaded, expect.anything());
+ });
+ });
+});
diff --git a/src/sdkBase.ts b/src/sdkBase.ts
new file mode 100644
index 00000000..c931e129
--- /dev/null
+++ b/src/sdkBase.ts
@@ -0,0 +1,399 @@
+import { version } from '../package.json';
+import { Uid2Identity } from './Uid2Identity';
+import { IdentityStatus, notifyInitCallback } from './Uid2InitCallbacks';
+import { Uid2Options, isUID2OptionsOrThrow } from './Uid2Options';
+import { Logger, MakeLogger } from './sdk/logger';
+import { Uid2ApiClient } from './uid2ApiClient';
+import { EventType, Uid2CallbackHandler, Uid2CallbackManager } from './uid2CallbackManager';
+import {
+ ClientSideIdentityOptions,
+ isClientSideIdentityOptionsOrThrow,
+} from './uid2ClientSideIdentityOptions';
+import { isNormalizedPhone, normalizeEmail } from './uid2DiiNormalization';
+import { isBase64Hash } from './uid2HashedDii';
+import { UID2PromiseHandler } from './uid2PromiseHandler';
+import { UID2StorageManager } from './uid2StorageManager';
+import { hashAndEncodeIdentifier } from './encoding/hash';
+
+function hasExpired(expiry: number, now = Date.now()) {
+ return expiry <= now;
+}
+export type UID2Setup = {
+ callbacks: Uid2CallbackHandler[] | undefined;
+};
+export type CallbackContainer = { callback?: () => void };
+
+export type ProductName = 'UID2' | 'EUID';
+export type ProductDetails = {
+ name: ProductName;
+ cookieName: string;
+ localStorageKey: string;
+ defaultBaseUrl: string;
+};
+
+export abstract class UID2SdkBase {
+ static get VERSION() {
+ return version;
+ }
+ static get DEFAULT_REFRESH_RETRY_PERIOD_MS() {
+ return 5000;
+ }
+ static IdentityStatus = IdentityStatus;
+ static EventType = EventType;
+
+ // Push functions to this array to receive event notifications
+ public callbacks: Uid2CallbackHandler[] = [];
+
+ // Dependencies initialised on construction
+ private _logger: Logger;
+ private _tokenPromiseHandler: UID2PromiseHandler;
+ protected _callbackManager: Uid2CallbackManager;
+
+ // Dependencies initialised on call to init due to requirement for options
+ private _storageManager: UID2StorageManager | undefined;
+ private _apiClient: Uid2ApiClient | undefined;
+
+ // State
+ private _product: ProductDetails;
+ private _opts: Uid2Options = {};
+ private _identity: Uid2Identity | null | undefined;
+ private _initComplete = false;
+
+ // Sets up nearly everything, but does not run SdkLoaded callbacks - derived classes must run them.
+ protected constructor(
+ existingCallbacks: Uid2CallbackHandler[] | undefined = undefined,
+ product: ProductDetails
+ ) {
+ this._product = product;
+ this._logger = MakeLogger(console, product.name);
+ const exception = new Error();
+ this._logger.log(`Constructing an SDK!`, exception.stack);
+ if (existingCallbacks) this.callbacks = existingCallbacks;
+
+ this._tokenPromiseHandler = new UID2PromiseHandler(this);
+ this._callbackManager = new Uid2CallbackManager(
+ this,
+ this._product.name,
+ () => this.getIdentity(),
+ this._logger
+ );
+ }
+
+ public init(opts: Uid2Options) {
+ this.initInternal(opts);
+ }
+
+ public getAdvertisingToken() {
+ return this.getIdentity()?.advertising_token ?? undefined;
+ }
+
+ public async setIdentityFromEmail(email: string, opts: ClientSideIdentityOptions) {
+ this._logger.log('Sending request', email);
+ this.throwIfInitNotComplete('Cannot set identity before calling init.');
+ isClientSideIdentityOptionsOrThrow(opts);
+
+ const normalizedEmail = normalizeEmail(email);
+ if (normalizedEmail === undefined) {
+ throw new Error('Invalid email address');
+ }
+
+ const emailHash = await hashAndEncodeIdentifier(email);
+ await this.callCstgAndSetIdentity({ emailHash: emailHash }, opts);
+ }
+
+ public async setIdentityFromEmailHash(emailHash: string, opts: ClientSideIdentityOptions) {
+ this.throwIfInitNotComplete('Cannot set identity before calling init.');
+ isClientSideIdentityOptionsOrThrow(opts);
+
+ if (!isBase64Hash(emailHash)) {
+ throw new Error('Invalid hash');
+ }
+
+ await this.callCstgAndSetIdentity({ emailHash: emailHash }, opts);
+ }
+
+ public setIdentity(identity: Uid2Identity) {
+ if (this._apiClient) this._apiClient.abortActiveRequests();
+ const validatedIdentity = this.validateAndSetIdentity(identity);
+ if (validatedIdentity) {
+ this.triggerRefreshOrSetTimer(validatedIdentity);
+ this._callbackManager.runCallbacks(EventType.IdentityUpdated, {});
+ }
+ }
+
+ public getIdentity(): Uid2Identity | null {
+ return this._identity && !this.temporarilyUnavailable() ? this._identity : null;
+ }
+ // When the SDK has been initialized, this function should return the token
+ // from the most recent refresh request, if there is a request, wait for the
+ // new token. Otherwise, returns a promise which will be resolved after init.
+ public getAdvertisingTokenAsync() {
+ const token = this.getAdvertisingToken();
+ return this._tokenPromiseHandler.createMaybeDeferredPromise(token ?? null);
+ }
+
+ /**
+ * Deprecated
+ */
+ public isLoginRequired() {
+ return this.hasIdentity();
+ }
+
+ public hasIdentity() {
+ if (!this._initComplete) return undefined;
+ return !(this.isLoggedIn() || this._apiClient?.hasActiveRequests());
+ }
+
+ public disconnect() {
+ this.abort(`${this._product.name} SDK disconnected.`);
+ // Note: This silently fails to clear the cookie if init hasn't been called and a cookieDomain is used!
+ if (this._storageManager) this._storageManager.removeValues();
+ else
+ new UID2StorageManager(
+ {},
+ this._product.cookieName,
+ this._product.localStorageKey
+ ).removeValues();
+ this._identity = undefined;
+ this._callbackManager.runCallbacks(EventType.IdentityUpdated, {
+ identity: null,
+ });
+ }
+
+ // Note: This doesn't invoke callbacks. It's a hard, silent reset.
+ public abort(reason?: string) {
+ this._initComplete = true;
+ this._tokenPromiseHandler.rejectAllPromises(
+ reason ?? new Error(`${this._product.name} SDK aborted.`)
+ );
+ if (this._refreshTimerId) {
+ clearTimeout(this._refreshTimerId);
+ this._refreshTimerId = null;
+ }
+ if (this._apiClient) this._apiClient.abortActiveRequests();
+ }
+
+ private initInternal(opts: Uid2Options | unknown) {
+ if (this._initComplete) {
+ throw new TypeError('Calling init() more than once is not allowed');
+ }
+ if (!isUID2OptionsOrThrow(opts))
+ throw new TypeError(`Options provided to ${this._product.name} init couldn't be validated.`);
+
+ this._opts = opts;
+ this._storageManager = new UID2StorageManager(
+ { ...opts },
+ this._product.cookieName,
+ this._product.localStorageKey
+ );
+ this._apiClient = new Uid2ApiClient(opts, this._product.defaultBaseUrl, this._product.name);
+ this._tokenPromiseHandler.registerApiClient(this._apiClient);
+
+ let identity;
+ if (this._opts.identity) {
+ identity = this._opts.identity;
+ } else {
+ identity = this._storageManager.loadIdentityWithFallback();
+ }
+ const validatedIdentity = this.validateAndSetIdentity(identity);
+ if (validatedIdentity) this.triggerRefreshOrSetTimer(validatedIdentity);
+ this._initComplete = true;
+ this._callbackManager?.runCallbacks(EventType.InitCompleted, {});
+ }
+
+ private isLoggedIn() {
+ return this._identity && !hasExpired(this._identity.refresh_expires);
+ }
+
+ private temporarilyUnavailable() {
+ if (!this._identity && this._apiClient?.hasActiveRequests()) return true;
+ if (
+ this._identity &&
+ hasExpired(this._identity.identity_expires) &&
+ !hasExpired(this._identity.refresh_expires)
+ )
+ return true;
+ return false;
+ }
+
+ private getIdentityStatus(identity: Uid2Identity | null):
+ | {
+ valid: true;
+ identity: Uid2Identity;
+ errorMessage: string;
+ status: IdentityStatus;
+ }
+ | {
+ valid: false;
+ errorMessage: string;
+ status: IdentityStatus;
+ identity: null;
+ } {
+ if (!identity) {
+ return {
+ valid: false,
+ errorMessage: 'Identity not available',
+ status: IdentityStatus.NO_IDENTITY,
+ identity: null,
+ };
+ }
+ if (!identity.advertising_token) {
+ return {
+ valid: false,
+ errorMessage: 'advertising_token is not available or is not valid',
+ status: IdentityStatus.INVALID,
+ identity: null,
+ };
+ }
+ if (!identity.refresh_token) {
+ return {
+ valid: false,
+ errorMessage: 'refresh_token is not available or is not valid',
+ status: IdentityStatus.INVALID,
+ identity: null,
+ };
+ }
+ if (hasExpired(identity.refresh_expires, Date.now())) {
+ return {
+ valid: false,
+ errorMessage: 'Identity expired, refresh expired',
+ status: IdentityStatus.REFRESH_EXPIRED,
+ identity: null,
+ };
+ }
+ if (hasExpired(identity.identity_expires, Date.now())) {
+ return {
+ valid: true,
+ errorMessage: 'Identity expired, refresh still valid',
+ status: IdentityStatus.EXPIRED,
+ identity,
+ };
+ }
+ if (typeof this._identity === 'undefined')
+ return {
+ valid: true,
+ identity,
+ status: IdentityStatus.ESTABLISHED,
+ errorMessage: 'Identity established',
+ };
+ return {
+ valid: true,
+ identity,
+ status: IdentityStatus.REFRESHED,
+ errorMessage: 'Identity refreshed',
+ };
+ }
+
+ private validateAndSetIdentity(
+ identity: Uid2Identity | null,
+ status?: IdentityStatus,
+ statusText?: string
+ ): Uid2Identity | null {
+ if (!this._storageManager) throw new Error('Cannot set identity before calling init.');
+ const validity = this.getIdentityStatus(identity);
+ if (
+ validity.identity &&
+ validity.identity?.advertising_token === this._identity?.advertising_token
+ )
+ return validity.identity;
+
+ this._identity = validity.identity;
+ if (validity.identity) {
+ this._storageManager.setValue(validity.identity);
+ } else {
+ this.abort();
+ this._storageManager.removeValues();
+ }
+ notifyInitCallback(
+ this._opts,
+ status ?? validity.status,
+ statusText ?? validity.errorMessage,
+ this.getAdvertisingToken(),
+ this._logger
+ );
+ return validity.identity;
+ }
+
+ private triggerRefreshOrSetTimer(validIdentity: Uid2Identity) {
+ if (hasExpired(validIdentity.refresh_from, Date.now())) {
+ this.refreshToken(validIdentity);
+ } else {
+ this.setRefreshTimer();
+ }
+ }
+
+ private _refreshTimerId: ReturnType | null = null;
+
+ private setRefreshTimer() {
+ const timeout = this._opts?.refreshRetryPeriod ?? UID2SdkBase.DEFAULT_REFRESH_RETRY_PERIOD_MS;
+ if (this._refreshTimerId) {
+ clearTimeout(this._refreshTimerId);
+ }
+ this._refreshTimerId = setTimeout(() => {
+ if (this.isLoginRequired()) return;
+ const validatedIdentity = this.validateAndSetIdentity(
+ this._storageManager?.loadIdentity() ?? null
+ );
+ if (validatedIdentity) this.triggerRefreshOrSetTimer(validatedIdentity);
+ this._refreshTimerId = null;
+ }, timeout);
+ }
+
+ private refreshToken(identity: Uid2Identity) {
+ const apiClient = this._apiClient;
+ if (!apiClient) throw new Error('Cannot refresh the token before calling init.');
+
+ apiClient
+ .callRefreshApi(identity)
+ .then(
+ (response) => {
+ switch (response.status) {
+ case 'success':
+ this.validateAndSetIdentity(
+ response.identity,
+ IdentityStatus.REFRESHED,
+ 'Identity refreshed'
+ );
+ this.setRefreshTimer();
+ break;
+ case 'optout':
+ this.validateAndSetIdentity(null, IdentityStatus.OPTOUT, 'User opted out');
+ break;
+ case 'expired_token':
+ this.validateAndSetIdentity(
+ null,
+ IdentityStatus.REFRESH_EXPIRED,
+ 'Refresh token expired'
+ );
+ break;
+ }
+ },
+ (reason) => {
+ this._logger.warn(`Encountered an error refreshing the token`, reason);
+ this.validateAndSetIdentity(identity);
+ if (!hasExpired(identity.refresh_expires, Date.now())) this.setRefreshTimer();
+ }
+ )
+ .then(
+ () => {
+ this._callbackManager.runCallbacks(EventType.IdentityUpdated, {});
+ },
+ (reason) => this._logger.warn(`Callbacks on identity event failed.`, reason)
+ );
+ }
+
+ protected async callCstgAndSetIdentity(
+ request: { emailHash: string } | { phoneHash: string },
+ opts: ClientSideIdentityOptions
+ ) {
+ const cstgResult = await this._apiClient!.callCstgApi(request, opts);
+
+ this.setIdentity(cstgResult.identity);
+ }
+
+ protected throwIfInitNotComplete(message: string) {
+ if (!this._initComplete) {
+ throw new Error(message);
+ }
+ }
+}
diff --git a/src/uid2ApiClient.ts b/src/uid2ApiClient.ts
index c2b70ea5..93aa91d7 100644
--- a/src/uid2ApiClient.ts
+++ b/src/uid2ApiClient.ts
@@ -1,4 +1,4 @@
-import { UID2SdkBase } from './uid2Sdk';
+import { ProductName, UID2SdkBase } from './sdkBase';
import { isValidIdentity, Uid2Identity } from './Uid2Identity';
import { UID2CstgBox } from './uid2CstgBox';
import { exportPublicKey } from './uid2CstgCrypto';
@@ -100,9 +100,9 @@ export type Uid2ApiClientOptions = {
export class Uid2ApiClient {
private _baseUrl: string;
private _clientVersion: string;
- private _productName: string;
+ private _productName: ProductName;
private _requestsInFlight: XMLHttpRequest[] = [];
- constructor(opts: Uid2ApiClientOptions, defaultBaseUrl: string, productName: string) {
+ constructor(opts: Uid2ApiClientOptions, defaultBaseUrl: string, productName: ProductName) {
this._baseUrl = opts.baseUrl ?? defaultBaseUrl;
this._productName = productName;
this._clientVersion = productName.toLowerCase() + '-sdk-' + UID2SdkBase.VERSION;
@@ -135,7 +135,7 @@ export class Uid2ApiClient {
this._requestsInFlight.push(req);
req.overrideMimeType('text/plain');
req.open('POST', url, true);
- req.setRequestHeader('X-UID2-Client-Version', this._clientVersion); // TODO: EUID
+ req.setRequestHeader('X-UID2-Client-Version', this._clientVersion); // N.B. EUID and UID2 currently both use the same header
let resolvePromise: (result: RefreshResult) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let rejectPromise: (reason?: any) => void;
@@ -201,8 +201,11 @@ export class Uid2ApiClient {
data: { emailHash: string } | { phoneHash: string },
opts: ClientSideIdentityOptions
): Promise {
+ const optoutPayload = this._productName == 'EUID' ? { optout_check: 1 } : {};
const request =
- 'emailHash' in data ? { email_hash: data.emailHash } : { phone_hash: data.phoneHash };
+ 'emailHash' in data
+ ? { email_hash: data.emailHash, ...optoutPayload }
+ : { phone_hash: data.phoneHash, ...optoutPayload };
const box = await UID2CstgBox.build(stripPublicKeyPrefix(opts.serverPublicKey));
diff --git a/src/uid2CallbackManager.ts b/src/uid2CallbackManager.ts
index 898808fc..55b528a7 100644
--- a/src/uid2CallbackManager.ts
+++ b/src/uid2CallbackManager.ts
@@ -1,4 +1,4 @@
-import { UID2SdkBase } from './uid2Sdk';
+import { UID2SdkBase } from './sdkBase';
import { Uid2Identity } from './Uid2Identity';
import { Logger } from './sdk/logger';
@@ -20,22 +20,26 @@ export class Uid2CallbackManager {
private _getIdentity: () => Uid2Identity | null | undefined;
private _logger: Logger;
private _sdk: UID2SdkBase;
+ private _productName: string;
constructor(
sdk: UID2SdkBase,
+ productName: string,
getIdentity: () => Uid2Identity | null | undefined,
logger: Logger
) {
+ this._productName = productName;
this._logger = logger;
this._getIdentity = getIdentity;
this._sdk = sdk;
this._sdk.callbacks.push = this.callbackPushInterceptor.bind(this);
}
- private static _sentSdkLoaded = false; //TODO: This needs to be fixed for EUID!
+ private static _sentSdkLoaded: Record = {};
private _sentInit = false;
private callbackPushInterceptor(...args: Uid2CallbackHandler[]) {
for (const c of args) {
- if (Uid2CallbackManager._sentSdkLoaded) this.safeRunCallback(c, EventType.SdkLoaded, {});
+ if (Uid2CallbackManager._sentSdkLoaded[this._productName])
+ this.safeRunCallback(c, EventType.SdkLoaded, {});
if (this._sentInit)
this.safeRunCallback(c, EventType.InitCompleted, {
identity: this._getIdentity() ?? null,
@@ -46,7 +50,7 @@ export class Uid2CallbackManager {
public runCallbacks(event: EventType, payload: Uid2CallbackPayload) {
if (event === EventType.InitCompleted) this._sentInit = true;
- if (event === EventType.SdkLoaded) Uid2CallbackManager._sentSdkLoaded = true;
+ if (event === EventType.SdkLoaded) Uid2CallbackManager._sentSdkLoaded[this._productName] = true;
if (!this._sentInit && event !== EventType.SdkLoaded) return;
const enrichedPayload = {
diff --git a/src/uid2PromiseHandler.ts b/src/uid2PromiseHandler.ts
index dcbf4318..347bc618 100644
--- a/src/uid2PromiseHandler.ts
+++ b/src/uid2PromiseHandler.ts
@@ -1,4 +1,4 @@
-import { UID2SdkBase } from './uid2Sdk';
+import { UID2SdkBase } from './sdkBase';
import { EventType, Uid2CallbackPayload } from './uid2CallbackManager';
import { Uid2ApiClient } from './uid2ApiClient';
diff --git a/src/uid2Sdk.ts b/src/uid2Sdk.ts
index 13dd79e0..51b58c0c 100644
--- a/src/uid2Sdk.ts
+++ b/src/uid2Sdk.ts
@@ -1,404 +1,28 @@
-import { version } from '../package.json';
-import { Uid2Identity } from './Uid2Identity';
-import { IdentityStatus, notifyInitCallback } from './Uid2InitCallbacks';
-import { Uid2Options, isUID2OptionsOrThrow } from './Uid2Options';
-import { Logger, MakeLogger } from './sdk/logger';
-import { Uid2ApiClient } from './uid2ApiClient';
-import { EventType, Uid2CallbackHandler, Uid2CallbackManager } from './uid2CallbackManager';
+import { EventType, Uid2CallbackHandler } from './uid2CallbackManager';
import {
ClientSideIdentityOptions,
isClientSideIdentityOptionsOrThrow,
} from './uid2ClientSideIdentityOptions';
-import { isNormalizedPhone, normalizeEmail } from './uid2DiiNormalization';
+import { isNormalizedPhone } from './uid2DiiNormalization';
import { isBase64Hash } from './uid2HashedDii';
-import { UID2PromiseHandler } from './uid2PromiseHandler';
-import { UID2StorageManager } from './uid2StorageManager';
import { hashAndEncodeIdentifier } from './encoding/hash';
-
-function hasExpired(expiry: number, now = Date.now()) {
- return expiry <= now;
-}
-
-type CallbackContainer = { callback?: () => void };
-
-type ProductName = 'UID2' | 'EUID';
-type ProductDetails = {
- name: ProductName;
- cookieName: string;
- localStorageKey: string;
- defaultBaseUrl: string;
-};
-
-export abstract class UID2SdkBase {
- static get VERSION() {
- return version;
- }
- static get DEFAULT_REFRESH_RETRY_PERIOD_MS() {
- return 5000;
- }
- static IdentityStatus = IdentityStatus;
- static EventType = EventType;
-
- // Push functions to this array to receive event notifications
- public callbacks: Uid2CallbackHandler[] = [];
-
- // Dependencies initialised on construction
- private _logger: Logger;
- private _tokenPromiseHandler: UID2PromiseHandler;
- protected _callbackManager: Uid2CallbackManager;
-
- // Dependencies initialised on call to init due to requirement for options
- private _storageManager: UID2StorageManager | undefined;
- private _apiClient: Uid2ApiClient | undefined;
-
- // State
- private _product: ProductDetails;
- private _opts: Uid2Options = {};
- private _identity: Uid2Identity | null | undefined;
- private _initComplete = false;
-
- // Sets up nearly everything, but does not run SdkLoaded callbacks - derived classes must run them.
- protected constructor(
- existingCallbacks: Uid2CallbackHandler[] | undefined = undefined,
- product: ProductDetails
- ) {
- this._product = product;
- this._logger = MakeLogger(console, product.name);
- if (existingCallbacks) this.callbacks = existingCallbacks;
-
- this._tokenPromiseHandler = new UID2PromiseHandler(this);
- this._callbackManager = new Uid2CallbackManager(this, () => this.getIdentity(), this._logger);
- }
-
- public init(opts: Uid2Options) {
- this.initInternal(opts);
- }
-
- public getAdvertisingToken() {
- return this.getIdentity()?.advertising_token ?? undefined;
- }
-
- public async setIdentityFromEmail(email: string, opts: ClientSideIdentityOptions) {
- this.throwIfInitNotComplete('Cannot set identity before calling init.');
- isClientSideIdentityOptionsOrThrow(opts);
-
- const normalizedEmail = normalizeEmail(email);
- if (normalizedEmail === undefined) {
- throw new Error('Invalid email address');
- }
-
- const emailHash = await hashAndEncodeIdentifier(email);
- await this.callCstgAndSetIdentity({ emailHash: emailHash }, opts);
- }
-
- public async setIdentityFromEmailHash(emailHash: string, opts: ClientSideIdentityOptions) {
- this.throwIfInitNotComplete('Cannot set identity before calling init.');
- isClientSideIdentityOptionsOrThrow(opts);
-
- if (!isBase64Hash(emailHash)) {
- throw new Error('Invalid hash');
- }
-
- await this.callCstgAndSetIdentity({ emailHash: emailHash }, opts);
- }
-
- public setIdentity(identity: Uid2Identity) {
- if (this._apiClient) this._apiClient.abortActiveRequests();
- const validatedIdentity = this.validateAndSetIdentity(identity);
- if (validatedIdentity) {
- this.triggerRefreshOrSetTimer(validatedIdentity);
- this._callbackManager.runCallbacks(EventType.IdentityUpdated, {});
- }
- }
-
- public getIdentity(): Uid2Identity | null {
- return this._identity && !this.temporarilyUnavailable() ? this._identity : null;
- }
- // When the SDK has been initialized, this function should return the token
- // from the most recent refresh request, if there is a request, wait for the
- // new token. Otherwise, returns a promise which will be resolved after init.
- public getAdvertisingTokenAsync() {
- const token = this.getAdvertisingToken();
- return this._tokenPromiseHandler.createMaybeDeferredPromise(token ?? null);
- }
-
- /**
- * Deprecated
- */
- public isLoginRequired() {
- return this.hasIdentity();
- }
-
- public hasIdentity() {
- if (!this._initComplete) return undefined;
- return !(this.isLoggedIn() || this._apiClient?.hasActiveRequests());
- }
-
- public disconnect() {
- this.abort(`${this._product.name} SDK disconnected.`);
- // Note: This silently fails to clear the cookie if init hasn't been called and a cookieDomain is used!
- if (this._storageManager) this._storageManager.removeValues();
- else
- new UID2StorageManager(
- {},
- this._product.cookieName,
- this._product.localStorageKey
- ).removeValues();
- this._identity = undefined;
- this._callbackManager.runCallbacks(EventType.IdentityUpdated, {
- identity: null,
- });
- }
-
- // Note: This doesn't invoke callbacks. It's a hard, silent reset.
- public abort(reason?: string) {
- this._initComplete = true;
- this._tokenPromiseHandler.rejectAllPromises(
- reason ?? new Error(`${this._product.name} SDK aborted.`)
- );
- if (this._refreshTimerId) {
- clearTimeout(this._refreshTimerId);
- this._refreshTimerId = null;
- }
- if (this._apiClient) this._apiClient.abortActiveRequests();
- }
-
- private initInternal(opts: Uid2Options | unknown) {
- if (this._initComplete) {
- throw new TypeError('Calling init() more than once is not allowed');
- }
- if (!isUID2OptionsOrThrow(opts))
- throw new TypeError(`Options provided to ${this._product.name} init couldn't be validated.`);
-
- this._opts = opts;
- this._storageManager = new UID2StorageManager(
- { ...opts },
- this._product.cookieName,
- this._product.localStorageKey
- );
- this._apiClient = new Uid2ApiClient(opts, this._product.defaultBaseUrl, this._product.name);
- this._tokenPromiseHandler.registerApiClient(this._apiClient);
-
- let identity;
- if (this._opts.identity) {
- identity = this._opts.identity;
- } else {
- identity = this._storageManager.loadIdentityWithFallback();
- }
- const validatedIdentity = this.validateAndSetIdentity(identity);
- if (validatedIdentity) this.triggerRefreshOrSetTimer(validatedIdentity);
- this._initComplete = true;
- this._callbackManager?.runCallbacks(EventType.InitCompleted, {});
- }
-
- private isLoggedIn() {
- return this._identity && !hasExpired(this._identity.refresh_expires);
- }
-
- private temporarilyUnavailable() {
- if (!this._identity && this._apiClient?.hasActiveRequests()) return true;
- if (
- this._identity &&
- hasExpired(this._identity.identity_expires) &&
- !hasExpired(this._identity.refresh_expires)
- )
- return true;
- return false;
- }
-
- private getIdentityStatus(identity: Uid2Identity | null):
- | {
- valid: true;
- identity: Uid2Identity;
- errorMessage: string;
- status: IdentityStatus;
- }
- | {
- valid: false;
- errorMessage: string;
- status: IdentityStatus;
- identity: null;
- } {
- if (!identity) {
- return {
- valid: false,
- errorMessage: 'Identity not available',
- status: IdentityStatus.NO_IDENTITY,
- identity: null,
- };
- }
- if (!identity.advertising_token) {
- return {
- valid: false,
- errorMessage: 'advertising_token is not available or is not valid',
- status: IdentityStatus.INVALID,
- identity: null,
- };
- }
- if (!identity.refresh_token) {
- return {
- valid: false,
- errorMessage: 'refresh_token is not available or is not valid',
- status: IdentityStatus.INVALID,
- identity: null,
- };
- }
- if (hasExpired(identity.refresh_expires, Date.now())) {
- return {
- valid: false,
- errorMessage: 'Identity expired, refresh expired',
- status: IdentityStatus.REFRESH_EXPIRED,
- identity: null,
- };
- }
- if (hasExpired(identity.identity_expires, Date.now())) {
- return {
- valid: true,
- errorMessage: 'Identity expired, refresh still valid',
- status: IdentityStatus.EXPIRED,
- identity,
- };
- }
- if (typeof this._identity === 'undefined')
- return {
- valid: true,
- identity,
- status: IdentityStatus.ESTABLISHED,
- errorMessage: 'Identity established',
- };
- return {
- valid: true,
- identity,
- status: IdentityStatus.REFRESHED,
- errorMessage: 'Identity refreshed',
- };
- }
-
- private validateAndSetIdentity(
- identity: Uid2Identity | null,
- status?: IdentityStatus,
- statusText?: string
- ): Uid2Identity | null {
- if (!this._storageManager) throw new Error('Cannot set identity before calling init.');
- const validity = this.getIdentityStatus(identity);
- if (
- validity.identity &&
- validity.identity?.advertising_token === this._identity?.advertising_token
- )
- return validity.identity;
-
- this._identity = validity.identity;
- if (validity.identity) {
- this._storageManager.setValue(validity.identity);
- } else {
- this.abort();
- this._storageManager.removeValues();
- }
- notifyInitCallback(
- this._opts,
- status ?? validity.status,
- statusText ?? validity.errorMessage,
- this.getAdvertisingToken(),
- this._logger
- );
- return validity.identity;
- }
-
- private triggerRefreshOrSetTimer(validIdentity: Uid2Identity) {
- if (hasExpired(validIdentity.refresh_from, Date.now())) {
- this.refreshToken(validIdentity);
- } else {
- this.setRefreshTimer();
- }
- }
-
- private _refreshTimerId: ReturnType | null = null;
-
- private setRefreshTimer() {
- const timeout = this._opts?.refreshRetryPeriod ?? UID2.DEFAULT_REFRESH_RETRY_PERIOD_MS;
- if (this._refreshTimerId) {
- clearTimeout(this._refreshTimerId);
- }
- this._refreshTimerId = setTimeout(() => {
- if (this.isLoginRequired()) return;
- const validatedIdentity = this.validateAndSetIdentity(
- this._storageManager?.loadIdentity() ?? null
- );
- if (validatedIdentity) this.triggerRefreshOrSetTimer(validatedIdentity);
- this._refreshTimerId = null;
- }, timeout);
- }
-
- private refreshToken(identity: Uid2Identity) {
- const apiClient = this._apiClient;
- if (!apiClient) throw new Error('Cannot refresh the token before calling init.');
-
- apiClient
- .callRefreshApi(identity)
- .then(
- (response) => {
- switch (response.status) {
- case 'success':
- this.validateAndSetIdentity(
- response.identity,
- IdentityStatus.REFRESHED,
- 'Identity refreshed'
- );
- this.setRefreshTimer();
- break;
- case 'optout':
- this.validateAndSetIdentity(null, IdentityStatus.OPTOUT, 'User opted out');
- break;
- case 'expired_token':
- this.validateAndSetIdentity(
- null,
- IdentityStatus.REFRESH_EXPIRED,
- 'Refresh token expired'
- );
- break;
- }
- },
- (reason) => {
- this._logger.warn(`Encountered an error refreshing the token`, reason);
- this.validateAndSetIdentity(identity);
- if (!hasExpired(identity.refresh_expires, Date.now())) this.setRefreshTimer();
- }
- )
- .then(
- () => {
- this._callbackManager.runCallbacks(EventType.IdentityUpdated, {});
- },
- (reason) => this._logger.warn(`Callbacks on identity event failed.`, reason)
- );
- }
-
- protected async callCstgAndSetIdentity(
- request: { emailHash: string } | { phoneHash: string },
- opts: ClientSideIdentityOptions
- ) {
- const cstgResult = await this._apiClient!.callCstgApi(request, opts);
-
- this.setIdentity(cstgResult.identity);
- }
-
- protected throwIfInitNotComplete(message: string) {
- if (!this._initComplete) {
- throw new Error(message);
- }
- }
-}
+import { CallbackContainer, ProductDetails, UID2SdkBase, UID2Setup } from './sdkBase';
export class UID2 extends UID2SdkBase {
+ private static cookieName = '__uid_2';
// Deprecated. Integrators should never access the cookie directly!
static get COOKIE_NAME() {
- return '__uid_2';
+ console.warn(
+ 'Detected access to UID2.COOKIE_NAME. This is deprecated and will be removed in the future. Integrators should not access the cookie directly.'
+ );
+ return UID2.cookieName;
}
private static get Uid2Details(): ProductDetails {
return {
name: 'UID2',
defaultBaseUrl: 'https://prod.uidapi.com',
localStorageKey: 'UID2-sdk-identity',
- cookieName: UID2.COOKIE_NAME,
+ cookieName: UID2.cookieName,
};
}
@@ -451,9 +75,6 @@ export class UID2 extends UID2SdkBase {
}
}
-type UID2Setup = {
- callbacks: Uid2CallbackHandler[] | undefined;
-};
declare global {
interface Window {
__uid2: UID2 | UID2Setup | undefined;
diff --git a/webpack-dev-server.config.js b/webpack-dev-server.config.js
index 03ebe2cd..007b641d 100644
--- a/webpack-dev-server.config.js
+++ b/webpack-dev-server.config.js
@@ -3,7 +3,10 @@ const path = require('path');
module.exports = {
mode: 'development',
devtool: 'inline-source-map',
- entry: './src/uid2Sdk.ts',
+ entry: {
+ uid2: './src/uid2Sdk.ts',
+ euid: './src/euidSdk.ts',
+ },
module: {
rules: [
{
@@ -18,7 +21,7 @@ module.exports = {
},
output: {
path: path.resolve(__dirname, 'dist'),
- filename: 'uid2-sdk.js',
+ filename: '[name]-sdk.js',
},
devServer: {
headers: {