UNPKG

@ideem/zsm-client-sdk

Version:

ZSM makes 2FA easy and invisible for everyone, all the time, using advanced cryptography like MPC to establish cryptographic proof of the origin of any transaction or login attempt, while eliminating opportunities for social engineering. ZSM has no relian

268 lines (236 loc) 18.1 kB
import GLOBAL from './GlobalScoping.js'; // baseStack is a Map that holds the initial state of the event stack. It is defaulted to those classes that are ALWAYS present in the ZSMClient package, and // constitutes the minimum set of classes that are both expected in ALL permutations of the SDK, and which are REQUIRED for the ZSMClient package to function. // Additional classes are added contextually (their status STARTING at "PENDING") when the client app imports them, and CAN remove a baseStack entry entirely. // Each event inside represents a class used by the NPM Package's "main" export - ZSMClientSDK - and tracks its status throughout its initialization lifecycle // (status include: UNREGISTERED, PENDING, READY, or ERROR). Each time a class's status changes, the event stack is updated, and, once all classes reach their // respective READY statuses, the ZSMClient package is considered fully initialized, and a custom event is dispatched to notify the client app that the SDK is // finished loading and is ready for use. Additional possible event stack entries (at time of this writing) include: WORKER, PERFORMANCELOGGER, PASSKEYCLIENT. let baseStack = new Map([ ["SELECTEDCLIENT", "UNREGISTERED"], // Active client class - UMFAClient or FIDO2Client (dynamically set by client's import choice) - that's being imported ["RELYINGPARTY", "UNREGISTERED"], // The RelyingParty class (extended from RelyingPartyBase) used for registration/authentication calls to the RP Server ["WEBAUTHNCLIENT", "UNREGISTERED"] // The WebAuthnClient class (extended from WebAuthnClientBase) used to coordinate the MPCRound calls to the ZSM Server ]); // EventCoordinator is a class instance that both represents and manages the event stack, allows for dynamically updating class initialization statuses (along // with dispatching the relevant custom events associated with those changes), and provides a means for additional, plug-in NPM Packages' classes to be added, // monitored, and included in the overall ZSMClient package's readiness state. Note: the act of importing EventCoordinator (or any other package, really) will // initialize it as a singleton, so it manages its own state, and there is no concern if it is imported multiple times; it always refers to the same instance. class EventCoordinator { constructor(initialStack = baseStack) { this.coreSDKClass = null; // The core SDK class that is user-facing (if any; usually ZSMClientSDK) this._selectedClient = null; // The active customer-facing SDK client (UMFAClient or FIDO2Client) this._eventStack = new Map(initialStack); // Clone the initialStack provided to ensure it isn't modified by reference this.cleanState = new Map(initialStack); // Store the initial state of the event stack as cleanState, so it can be reset later // Even if the GLOBAL scope does not support event dispatches, while we will not be able to dispatch events, we can still manage the app's event stack this.scopeSupportsEventDispatch = (typeof GLOBAL.dispatchEvent === 'function' && typeof CustomEvent === 'function'); } get supported() { return this.scopeSupportsEventDispatch; } // Returns true/false if GLOBAL scope supports events get statusOptions() { return ['UNREGISTERED', 'PENDING', 'READY', 'ERROR']; } // Returns the possible status options for events get stack() { return this._eventStack; } // Returns the current event stack as a Map get length() { return [...this._eventStack.keys()].length; } // Returns count of all events in the stack get keys() { return [...this._eventStack.keys()]; } // Returns array of event names in the stack get values() { return [...this._eventStack.values()]; } // Returns array of event statuses in the stack get entries() { return [...this._eventStack.entries()]; } // Returns array of [name, status] pairs in the stack get object() { return Object.fromEntries(this.entries); } // Returns the event stack as a plain object get unregistered() { return this.lengthByStatus('UNREGISTERED'); } // Returns count of events with status "UNREGISTERED" get pending() { return this.lengthByStatus('PENDING'); } // Returns count of events with status "PENDING" get ready() { return this.lengthByStatus('READY'); } // Returns count of events with status "READY" get error() { return this.lengthByStatus('ERROR'); } // Returns count of events with status "ERROR" get incomplete() { return this.length - this.ready; } // Returns count of events that are NOT "READY" get containsErrors() { return this.error > 0; } // Returns true/false if any events' status is "ERROR" get done() { return (this.ready === this.length); } // Returns true/false if all events are "READY" get client() { return this._selectedClient; } // Returns the currently selected client (UMFA/FIDO2) get status() { return this.containsErrors ? 'ERROR' : this.done ? 'READY' : !!this.unregistered ? 'INCOMPLETE REGISTRATIONS' : 'PENDING'; } set client(value) { this._selectedClient = value; } // Returns the currently selected client (UMFA/FIDO2) set coreReady(value) { this.coreSDKClass.ready = !!value; } set coreErrs(value) { this.coreSDKClass.containsErrors = !!value; } setCoreSDKClass(classDesc, classRef) { const core = (this.coreSDKClass = classRef); // Set a reference to the core class on the singleton instance, then... core.ready = false; // ... set its default ready state to false core.containsErrors = false; // ... set its default containsErrors state to false Object.defineProperty(core, "finished", { // ... and define a "finished" function on the core object... value: this.finished.bind(this), // ... binding it to the method on this singleton instance enumerable: false, writable: false, configurable: false // ... and making it non-enumerable, non-writable, and non-configurable }); this.specifyClient(classDesc); // ... finally, specify the client class that is being initialized. } specifyClient(selectedClient) { if (!selectedClient || typeof selectedClient !== 'string' || !selectedClient.length) throw new TypeError(`[ZSM EventCoordinator] :: specifyClient :: Invalid 'selectedClient' value: "${selectedClient}". 'selectedClient' is required and expects a String.`); if (this.client == null) { this._eventStack.delete('SELECTEDCLIENT'); this.cleanState.delete('SELECTEDCLIENT'); this.client = selectedClient.toUpperCase(); } } summarizeEventStack (...triggerData) { let summaryObject = this.object, { client, containsErrors, done } = this; summaryObject[`counts (${this.length})`] = { total : this.length, unregistered : this.unregistered, pending : this.pending, ready : this.ready, error : this.error }; let opFn = (typeof console?.l === 'function') ? console.l : console.log; opFn(`[ZSM EventCoordinator] :: Event Update!\n${(!!triggerData.length) ? `(ACTIVE EVENT: '${triggerData.join(': ')}')\n` : ''} Status summary:\n`, Object.assign({client, containsErrors, done}, summaryObject)); } /** * @name dispatchEvent * @description Dispatches a custom event with the given name and detail. * @param {string} classDesc The name of the event to dispatch. * @param {Object} eventDetail The detail object to attach to the event. * @returns {void} */ dispatchEvent(eventName, eventDetail={}) { if (!this.scopeSupportsEventDispatch) return; GLOBAL.dispatchEvent(new CustomEvent(eventName, { detail: eventDetail })); } update (classDesc, eventStatus="PENDING") { if (!classDesc || typeof classDesc !== 'string' || !classDesc.length) throw new TypeError(`[ZSM EventCoordinator] :: update :: Invalid 'classDesc' value: "${classDesc}". 'classDesc' is required and expects a String.`); if(classDesc === 'SELECTEDCLIENT'){ if(!this.client) throw new TypeError(`[ZSM EventCoordinator] :: update :: Status for 'SELECTEDCLIENT' cannot be updated until a client is specified!`); classDesc = this.client; } classDesc = classDesc.toUpperCase(); eventStatus = (!eventStatus || typeof eventStatus !== 'string' || !eventStatus.length) ? "PENDING" : eventStatus.toUpperCase(); if (!this.statusOptions.includes(eventStatus)) throw new TypeError(`[ZSM EventCoordinator] :: update :: Invalid 'eventStatus' value: "${eventStatus}". Accepts: ${this.statusOptions.join(', ')}.`); this._eventStack.set(classDesc, eventStatus); if( this.containsErrors ) { const errorMsg = `[ZSM EventCoordinator] :: Initialization Error! :: The ZSMClient initialization contains errors and is unable to continue!]\nModules containing errors:\n - ${this.byStatus('ERROR').join('\n -')}.`; this.coreErrs = true; // Set the core SDK class error state to true if(window && window?.queueMicrotask) { queueMicrotask(() => this.dispatchEvent('ZSMClientError', { message: errorMsg, modules: this.byStatus('ERROR') })); }else this.dispatchEvent('ZSMClientError', { message: errorMsg, modules: this.object }); console.error(errorMsg); return this.object; // don't throw here } if (this.done) { this.coreReady = true; // Set the core SDK class ready state to true if(window && window?.queueMicrotask) { queueMicrotask(() => this.dispatchEvent('ZSMClientReady', { ready: true })); }else this.dispatchEvent('ZSMClientReady', { ready: true }); } if(GLOBAL.showEvents) this.summarizeEventStack(classDesc, eventStatus); return this.object; } /** * @name byStatus * @description Retrieves all event names that match a specific status. * @param {string} eventStatus The status to filter events by (accepts: UNREGISTERED, PENDING, READY, ERROR). * @throws {TypeError} If the eventStatus is invalid or not one of the allowed values. * @returns {Array} Returns an array of event names that match the specified status. */ byStatus(eventStatus) { if (!eventStatus || typeof eventStatus !== 'string' || !eventStatus.length || !["UNREGISTERED", "PENDING", "READY", "ERROR"].includes(eventStatus)) throw new Error(`[ZSM EventCoordinator] :: byStatus :: Invalid 'eventStatus' value: "${eventStatus}". Valid statuses are: UNREGISTERED, PENDING, READY, ERROR.`); return [...this._eventStack.entries().flatMap(([k, v]) => (v === eventStatus) ? [k] : [])]; } /** * @name lengthByStatus * @description Returns the count of events that match a specific status. * @param {string} eventStatus The status to filter events by (accepts: UNREGISTERED, PENDING, READY, ERROR). * @throws {TypeError} If the eventStatus is invalid or not one of the allowed values. * @returns {number} Returns the count of events with the specified status. */ lengthByStatus(eventStatus) { return this.byStatus(eventStatus).length || 0; } /** * @name reset * @description Resets the event stack to its initial state (cleanState). * @returns {Object} Returns the reset event stack object. */ reset() { this._eventStack = new Map(this.cleanState); if(window && window?.queueMicrotask) { queueMicrotask(() => this.dispatchEvent('ZSMClientEventStackReset', { stack: this.object })); } else this.dispatchEvent('ZSMClientEventStackReset', { stack: this.object }); return this.object; } /** * @name finished * @description Waits for the ZSMClient initialization to complete, either by checking the done state or by listening for the ZSMClientReady event. * @param {function|number} callbackFnOrTimeout A callback function to call when the initialization is done, or a timeout in milliseconds to wait before rejecting. * @param {function|any} callbackFnOrFirstArg An optional callback function to call when the initialization is done (if timeout is specified), or the first argument to pass to the callback function. * @param {array<any>} args Additional arguments to pass to the callback function. * @returns {Promise<boolean>} Resolves to true if the initialization is done, or rejects with an error if it takes too long or contains errors. * @throws {Error} If the initialization takes too long or contains errors. * @throws {Error} If some portion of the initialization fails. */ async finished(callbackFnOrTimeout, callbackFnOrFirstArg, ...args) { if (this.done) return Promise.resolve(true); let timeoutID, callbackFn, onSDKReady, onSDKError, terminatePoll = false, timeoutDuration = 20000; if(!isNaN(callbackFnOrTimeout)) { timeoutDuration = callbackFnOrTimeout; if(typeof callbackFnOrFirstArg === 'function') { callbackFn = callbackFnOrFirstArg; } }else if(typeof callbackFnOrTimeout === 'function') { callbackFn = callbackFnOrTimeout; args.unshift(callbackFnOrFirstArg); } return new Promise((resolve, reject) => { onSDKReady = () => { terminatePoll = true; if(callbackFn) callbackFn(...args); // Call the callback function if provided resolve(true); // Resolve the promise with a value of true }; onSDKError = (e) => { terminatePoll = true; reject(new Error(`[ZSM EventCoordinator] :: finished :: Initialization Error! :: ${e.message || e}\nModules containing errors:\n - ${this.byStatus('ERROR').join('\n -')}.`)); }; if (this.done) onSDKReady(); if (this.containsErrors) onSDKError({ detail: { modules: this.byStatus('ERROR') } }); const onTimeout = () => { terminatePoll = true; if (this.done) return; reject(new Error("[ZSM EventCoordinator] :: Timeout Error: ZSMClient initialization took too long to complete. Please check your network connection or server status.")); }; timeoutID = setTimeout(onTimeout, timeoutDuration); if (this.scopeSupportsEventDispatch) { GLOBAL.addEventListener('ZSMClientReady', onSDKReady, { once: true }); GLOBAL.addEventListener('ZSMClientError', onSDKError, { once: true }); } else { const poll = () => { if (this.done) { onSDKReady(); } else if (this.containsErrors) { onSDKError("[ZSM EventCoordinator] :: finished :: Error: ZSMClient initialization contains errors and is unable to continue."); } else if (!terminatePoll) setTimeout(poll, 200); }; poll(); } }) .catch(err => { throw new Error(`[ZSM EventCoordinator] :: finished :: Initialization Error! :: ${err.message || err}\nModules containing errors:\n - ${this.byStatus('ERROR').join('\n -')}.`); }) .finally(() => { clearTimeout(timeoutID); if (this.scopeSupportsEventDispatch) { GLOBAL.removeEventListener('ZSMClientReady', onSDKReady); GLOBAL.removeEventListener('ZSMClientError', onSDKError); } }); } } const eventCoordinator = new EventCoordinator(); // Create a singleton instance of EventCoordinator export default eventCoordinator; export { eventCoordinator };