UNPKG

@zoom/rivet

Version:

Zoom Rivet is a comprehensive toolkit built to help developers quickly integrate and manage server-side applications within the Zoom ecosystem. This tool currently supports Node.js, offering core functionalities like authentication, API wrappers, and even

1,081 lines (1,061 loc) 101 kB
import { createHmac, randomBytes } from 'node:crypto'; import { createServer as createServer$1 } from 'node:http'; import { createServer } from 'node:https'; import axios from 'axios'; import dayjs from 'dayjs'; import { Buffer as Buffer$1 } from 'node:buffer'; import { SignJWT, jwtVerify } from 'jose'; import FormData from 'form-data'; import os from 'node:os'; import { basename } from 'node:path'; /** * Guard if an object implements the {@link StateStore} interface — most notably, * `generateState()` and `verifyState(state: string)`. */ const isStateStore = (obj) => typeof obj.generateState === "function" && typeof obj.verifyState === "function"; const createRivetErrors = (errors) => ({ createError: (errorCode) => class extends Error { errorCode = errors[errorCode]; constructor(message, options) { const errorMessage = (message ?? (options?.cause instanceof Error ? options.cause.message : errorCode)); super(errorMessage, options); this.name = errorCode; Object.setPrototypeOf(this, new.target.prototype); } }, isError: (obj, key) => key ? Object.keys(errors).some((code) => code === key) && typeof obj.errorCode === "string" && obj.errorCode === errors[key] : typeof obj.errorCode === "string" }); const coreErrors = { ApiResponseError: "zoom_rivet_api_response_error", AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error", ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error", S2SRawResponseError: "zoom_rivet_s2s_raw_response_error", CommonHttpRequestError: "zoom_rivet_common_http_request_error", ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error", ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error", HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error", HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error", HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error", OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error", OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error", OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error", OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error", OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error", OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error", ProductClientConstructionError: "zoom_rivet_product_client_construction_error" }; const { createError: createCoreError, isError: isCoreError } = createRivetErrors(coreErrors); const ApiResponseError = createCoreError("ApiResponseError"); const AwsReceiverRequestError = createCoreError("AwsReceiverRequestError"); const ClientCredentialsRawResponseError = createCoreError("ClientCredentialsRawResponseError"); const S2SRawResponseError = createCoreError("S2SRawResponseError"); const CommonHttpRequestError = createCoreError("CommonHttpRequestError"); const ReceiverInconsistentStateError = createCoreError("ReceiverInconsistentStateError"); const ReceiverOAuthFlowError = createCoreError("ReceiverOAuthFlowError"); const HTTPReceiverConstructionError = createCoreError("HTTPReceiverConstructionError"); const HTTPReceiverPortNotNumberError = createCoreError("HTTPReceiverPortNotNumberError"); const HTTPReceiverRequestError = createCoreError("HTTPReceiverRequestError"); const OAuthInstallerNotInitializedError = createCoreError("OAuthInstallerNotInitializedError"); const OAuthTokenDoesNotExistError = createCoreError("OAuthTokenDoesNotExistError"); const OAuthTokenFetchFailedError = createCoreError("OAuthTokenFetchFailedError"); const OAuthTokenRawResponseError = createCoreError("OAuthTokenRawResponseError"); const OAuthTokenRefreshFailedError = createCoreError("OAuthTokenRefreshFailedError"); const OAuthStateVerificationFailedError = createCoreError("OAuthStateVerificationFailedError"); const ProductClientConstructionError = createCoreError("ProductClientConstructionError"); var LogLevel; (function (LogLevel) { LogLevel["ERROR"] = "error"; LogLevel["WARN"] = "warn"; LogLevel["INFO"] = "info"; LogLevel["DEBUG"] = "debug"; })(LogLevel || (LogLevel = {})); class ConsoleLogger { level; name; static labels = (() => { const entries = Object.entries(LogLevel); const map = entries.map(([key, value]) => [value, `[${key}] `]); return new Map(map); })(); static severity = { [LogLevel.ERROR]: 400, [LogLevel.WARN]: 300, [LogLevel.INFO]: 200, [LogLevel.DEBUG]: 100 }; constructor() { this.level = LogLevel.INFO; this.name = ""; } getLevel() { return this.level; } setLevel(level) { this.level = level; } setName(name) { this.name = name; } debug(...msg) { if (ConsoleLogger.isMoreOrEqualSevere(LogLevel.DEBUG, this.level)) { console.debug(ConsoleLogger.labels.get(LogLevel.DEBUG), this.name, ...msg); } } info(...msg) { if (ConsoleLogger.isMoreOrEqualSevere(LogLevel.INFO, this.level)) { console.info(ConsoleLogger.labels.get(LogLevel.INFO), this.name, ...msg); } } warn(...msg) { if (ConsoleLogger.isMoreOrEqualSevere(LogLevel.WARN, this.level)) { console.warn(ConsoleLogger.labels.get(LogLevel.WARN), this.name, ...msg); } } error(...msg) { if (ConsoleLogger.isMoreOrEqualSevere(LogLevel.ERROR, this.level)) { console.error(ConsoleLogger.labels.get(LogLevel.ERROR), this.name, ...msg); } } static isMoreOrEqualSevere(a, b) { return ConsoleLogger.severity[a] >= ConsoleLogger.severity[b]; } } class EventManager { endpoints; /** @internal */ listeners; constructor(endpoints) { this.endpoints = endpoints; this.listeners = {}; } appendListener(eventName, predicate, listener) { if (this.listeners[eventName]) { this.listeners[eventName].push({ predicate, listener }); } else { this.listeners[eventName] = [{ predicate, listener }]; } } filteredEvent(eventName, predicate, listener) { if (typeof predicate !== "function" || typeof listener !== "function") { throw new Error("Event predicate and listener must be of type function."); } this.appendListener(eventName, predicate, listener); } async emit(eventName, payload) { if (!this.listeners[eventName]) return; await Promise.all(this.listeners[eventName].map(async ({ predicate, listener }) => { if (typeof predicate !== "undefined" && !predicate(payload)) return; await Promise.resolve(listener(payload)); })); } event(eventName, listener) { if (typeof listener !== "function") { throw new Error("Event listener must be of type function."); } this.appendListener(eventName, undefined, listener); } withContext() { throw new Error("Method not implemented. Only to be used for type."); } } /** @internal */ const hashUrlValidationEvent = ({ payload: { plainToken } }, webhooksSecretToken) => ({ encryptedToken: createHmac("sha256", webhooksSecretToken).update(plainToken).digest("hex"), plainToken }); const isHashedUrlValidation = (obj) => typeof obj.encryptedToken === "string" && typeof obj.plainToken === "string"; const isRawUrlValidationEvent = (obj) => obj.event === "endpoint.url_validation" && typeof obj.payload.plainToken === "string"; const isSkeletonEvent = (obj) => typeof obj.event === "string"; class CommonHttpRequest { headers; payload; webhooksSecretToken; constructor(headers, payload, webhooksSecretToken) { this.headers = headers; this.payload = payload; this.webhooksSecretToken = webhooksSecretToken; } static buildFromAwsEvent({ body, headers, isBase64Encoded }, webhooksSecretToken) { try { const rawBody = body ?? ""; const decodedBody = isBase64Encoded ? Buffer.from(rawBody, "base64").toString("ascii") : rawBody; const payload = JSON.parse(decodedBody); return new CommonHttpRequest(headers, payload, webhooksSecretToken); } catch (err) { throw err instanceof SyntaxError ? new CommonHttpRequestError("Failed to parse payload string to JSON.", err) : err; } } static async buildFromIncomingMessage(incomingMessage, webhooksSecretToken) { const bufferAsString = () => { return new Promise((resolve, reject) => { const body = []; incomingMessage.on("data", (chunk) => body.push(chunk)); incomingMessage.on("error", (err) => { reject(err); }); incomingMessage.on("end", () => { resolve(Buffer.concat(body).toString()); }); }); }; try { const payload = JSON.parse(await bufferAsString()); return new CommonHttpRequest(incomingMessage.headers, payload, webhooksSecretToken); } catch (err) { if (err instanceof SyntaxError) { throw new CommonHttpRequestError("Failed to parse payload string to JSON.", err); } throw err; } } isEventVerified() { const { signature, requestTimestamp } = this.parseHeaders(); const messageToVerify = `v0:${requestTimestamp.toString()}:${JSON.stringify(this.payload)}`; const hashToVerify = createHmac("sha256", this.webhooksSecretToken).update(messageToVerify).digest("hex"); const signatureToVerify = `v0=${hashToVerify}`; return signatureToVerify === signature; } parseHeaders() { const findHeader = (header) => { const foundHeader = Object.keys(this.headers).find((key) => key.toLowerCase() === header.toLowerCase()); return foundHeader && this.headers[foundHeader]; }; const headerSignature = findHeader("x-zm-signature"); const headerRequestTimestamp = findHeader("x-zm-request-timestamp"); if (!headerSignature && !headerRequestTimestamp) { throw new CommonHttpRequestError("Request payload must have signature and request timestamp from Zoom."); } return { signature: headerSignature, requestTimestamp: Number(headerRequestTimestamp) }; } processEvent() { if (!isSkeletonEvent(this.payload)) { throw new CommonHttpRequestError("Request payload structure does not match expected from Zoom."); } if (!this.isEventVerified()) { throw new CommonHttpRequestError("Failed to verify event originated from Zoom."); } if (isRawUrlValidationEvent(this.payload)) { return hashUrlValidationEvent(this.payload, this.webhooksSecretToken); } return this.payload; } } var StatusCode; (function (StatusCode) { StatusCode[StatusCode["OK"] = 200] = "OK"; StatusCode[StatusCode["TEMPORARY_REDIRECT"] = 302] = "TEMPORARY_REDIRECT"; StatusCode[StatusCode["BAD_REQUEST"] = 400] = "BAD_REQUEST"; StatusCode[StatusCode["NOT_FOUND"] = 404] = "NOT_FOUND"; StatusCode[StatusCode["METHOD_NOT_ALLOWED"] = 405] = "METHOD_NOT_ALLOWED"; StatusCode[StatusCode["INTERNAL_SERVER_ERROR"] = 500] = "INTERNAL_SERVER_ERROR"; })(StatusCode || (StatusCode = {})); class AwsLambdaReceiver { eventEmitter; webhooksSecretToken; constructor({ webhooksSecretToken }) { this.webhooksSecretToken = webhooksSecretToken; } buildResponse(statusCode, body) { return { body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, statusCode }; } canInstall() { return false; } init({ eventEmitter }) { this.eventEmitter = eventEmitter; } start() { return async (event, context) => { console.debug("Processing Lambda event ", JSON.stringify(event), " with context ", JSON.stringify(context)); try { const request = CommonHttpRequest.buildFromAwsEvent(event, this.webhooksSecretToken); const processedEvent = request.processEvent(); if (isHashedUrlValidation(processedEvent)) { return this.buildResponse(StatusCode.OK, processedEvent); } else { await this.eventEmitter?.emit(processedEvent.event, processedEvent); return this.buildResponse(StatusCode.OK, { message: "Zoom event processed successfully." }); } } catch (err) { if (isCoreError(err, "CommonHttpRequestError")) { return this.buildResponse(StatusCode.BAD_REQUEST, { error: err.message }); } else { console.error(err); return this.buildResponse(StatusCode.INTERNAL_SERVER_ERROR, { error: "An unknown error occurred. Please try again later." }); } } }; } async stop() { return Promise.resolve(); } } const prependSlashes = (strs) => { const rawStrs = Array.isArray(strs) ? strs : [strs]; const mappedStrs = rawStrs.map((rawStr) => (rawStr.startsWith("/") ? rawStr : `/${rawStr}`)); return (Array.isArray(strs) ? mappedStrs : mappedStrs[0]); }; class TokenMemoryStore { currentToken; getLatestToken() { return this.currentToken; } storeToken(token) { this.currentToken = token; } } /** @internal */ const EXPIRATION_DELTA_SECONDS = 60; /** @internal */ const OAUTH_BASE_URL = "https://zoom.us"; /** @internal */ const OAUTH_TOKEN_PATH = "/oauth/token"; /** * {@link Auth} is the base implementation of authentication for Zoom's APIs. * * It only requires a `clientId` and `tokenStore`, as these options are shared across * all authentication implementations, namely OAuth and server-to-server auth (client * credentials, JWT, and server-to-server OAuth.) */ class Auth { clientId; clientSecret; tokenStore; logger; constructor({ clientId, clientSecret, tokenStore, logger }) { this.clientId = clientId; this.clientSecret = clientSecret; this.tokenStore = tokenStore ?? new TokenMemoryStore(); this.logger = logger; } getBasicAuthorization() { const clientCredentials = `${this.clientId}:${this.clientSecret}`; return Buffer$1.from(clientCredentials).toString("base64"); } isAlmostExpired(isoTime) { const currentDate = dayjs(); return dayjs(isoTime).diff(currentDate, "seconds") <= EXPIRATION_DELTA_SECONDS; } async makeOAuthTokenRequest(grantType, payload) { return await axios({ method: "POST", url: new URL(OAUTH_TOKEN_PATH, OAUTH_BASE_URL).toString(), headers: { Authorization: `Basic ${this.getBasicAuthorization()}`, "Content-Type": "application/x-www-form-urlencoded" }, data: new URLSearchParams({ grant_type: grantType, ...payload }), validateStatus: (status) => status >= 200 && status <= 299 }); } } const DEFAULT_EXPIRATION_SECONDS = 300; // 5 minutes /** @internal */ const ISSUER_URN = "urn:zoom:rivet-sdk"; class JwtStateStore { encodedSecret; expirationSeconds; constructor({ expirationSeconds, stateSecret }) { this.encodedSecret = new TextEncoder().encode(stateSecret); this.expirationSeconds = expirationSeconds ?? DEFAULT_EXPIRATION_SECONDS; } async generateState() { const issuedTime = dayjs(); const expirationTime = issuedTime.add(this.expirationSeconds, "seconds"); return await new SignJWT({ random: randomBytes(8).toString("hex") }) .setProtectedHeader({ alg: "HS256", typ: "JWT" }) .setExpirationTime(expirationTime.toDate()) .setIssuedAt(issuedTime.toDate()) .setIssuer(ISSUER_URN) .sign(this.encodedSecret); } async verifyState(state) { try { await jwtVerify(state, this.encodedSecret, { algorithms: ["HS256"], issuer: ISSUER_URN, typ: "JWT" }); } catch (err) { throw new OAuthStateVerificationFailedError(`Failed to verify OAuth state: ${err.name}.`, { cause: err }); } } } const DEFAULT_INSTALL_PATH = "/zoom/oauth/install"; const DEFAULT_CALLBACK_PATH = "/zoom/oauth/callback"; const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; const hasInstallerOptions = (obj) => typeof obj.installerOptions.redirectUri !== "undefined" && typeof obj.installerOptions.stateStore !== "undefined"; /** * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication * is initiated server-side, but requires manual authorization from a user, by redirecting the user to Zoom. * * In addition to all required fields from {@link AuthOptions}, this class requires a `redirectUri`, as this * value is appended to the authorization URL when the user is redirected to Zoom and subsequently redirected * back to an endpoint on this server. * * @see {@link https://developers.zoom.us/docs/integrations/oauth/ | OAuth - Zoom Developers} */ class InteractiveAuth extends Auth { installerOptions; async getAuthorizationUrl() { if (!this.installerOptions?.stateStore) { throw new OAuthInstallerNotInitializedError("Cannot generate authorization URL, state store not initialized."); } const authUrl = new URL(OAUTH_AUTHORIZE_PATH, OAUTH_BASE_URL); const generatedState = await Promise.resolve(this.installerOptions.stateStore.generateState()); const { searchParams } = authUrl; searchParams.set("client_id", this.clientId); searchParams.set("redirect_uri", this.getFullRedirectUri()); searchParams.set("response_type", "code"); searchParams.set("state", generatedState); return authUrl.toString(); } getFullRedirectUri() { if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { throw new OAuthInstallerNotInitializedError("Cannot generate full redirect URI, redirect URI or redirect URI path not initialized."); } return new URL(this.installerOptions.redirectUriPath, this.installerOptions.redirectUri).toString(); } // Don't return a type; we want it to be as narrow as possible (used for ReturnType). // eslint-disable-next-line @typescript-eslint/explicit-function-return-type setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }) { const updatedOptions = { directInstall: Boolean(directInstall), installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, redirectUri, redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }) }; this.installerOptions = updatedOptions; return updatedOptions; } } const mergeDefaultOptions = (options, defaultOptions) => ({ ...defaultOptions, ...options }); const withDefaultTemplate = (cardContent, buttonContent) => ` <html lang="en"> <head> <title>Zoom Rivet</title> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <script src="https://cdn.tailwindcss.com"></script> </head> <body> <div class="flex items-center justify-center min-h-screen bg-[#F4F9FF] bg-opacity-50"> <div class="rounded-lg border bg-card w-full max-w-md mx-4 shadow-lg"> <div class="flex flex-col p-6 space-y-1"> <h3 class="whitespace-nowrap tracking-tight text-3xl font-bold text-center text-[#006EF0]"> Zoom Rivet </h3> </div> <div class="p-6 space-y-4"> ${cardContent} </div> ${buttonContent ? `<div class="items-center p-6 flex justify-center"> <a href="${buttonContent.href}"> <button class="inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-11 rounded-md text-lg px-8 py-6 bg-[#006EF0] hover:bg-[#2681F2] text-white transition-all duration-300 ease-in-out transform scale-100"> ${buttonContent.text} </button> </a> </div>` : ""} </div> </div> </body> </html> `; /** * Get the default HTML template that is shown to the developer/user when they visit the * `installPath` endpoint, if Rivet currently has OAuth enabled. * * If `directInstall` is set to `true`, this function is not called; instead, the developer * is directly redirected to Zoom's OAuth page. */ const defaultInstallTemplate = (authUrl) => withDefaultTemplate(`<p class="text-sm text-gray-600 text-center">Click the button below to navigate to Zoom to authorize your application for use with Rivet.</p>`, { href: authUrl, text: "Authorize with Zoom" }); /** * Get the default HTML template that is shown to the developer/user when they successfully * authorize Rivet with a Zoom application. This is shown once they have already been redirected * to Zoom, and the authorization attempt with Rivet was successful. */ const defaultCallbackSuccessTemplate = () => withDefaultTemplate(`<p class="text-sm text-gray-600 text-center">Your application has been successfully authorized with Rivet!</p> <p class="text-sm text-gray-600 text-center">You may now close this page, or click the button below to redirect to Zoom's Marketplace.</p>`, { href: "https://marketplace.zoom.us", text: "Go to Marketplace" }); /** * Get the default HTML template that is shown to the developer when a known error occurs, meaning * that the error is a core Rivet error. */ const defaultCallbackKnownErrorTemplate = (errName, errMessage) => withDefaultTemplate(`<p class="text-sm text-gray-600 text-center">An error occurred authorizing Rivet with Zoom.</p> <p class="text-sm text-gray-600 text-center">[${errName}]: ${errMessage}</p>`); /** * Get the default HTML template that is shown to the developer when an unknown error occurs, * meaning that the error is not known to be a core Rivet error and was thrown and not wrapped elsewhere. */ const defaultCallbackUnknownErrorTemplate = () => withDefaultTemplate(`<p class="text-sm text-gray-600 text-center">An unknown error occurred authorizing Rivet with Zoom. Please see stacktrace for details.</p> <p class="text-sm text-gray-600 text-center">Please see stacktrace for further details.</p>`); const secureServerOptionKeys = [ "ALPNProtocols", "clientCertEngine", "enableTrace", "handshakeTimeout", "rejectUnauthorized", "requestCert", "sessionTimeout", "SNICallback", "ticketKeys", "pskCallback", "pskIdentityHint", "ca", "cert", "sigalgs", "ciphers", "clientCertEngine", "crl", "dhparam", "ecdhCurve", "honorCipherOrder", "key", "privateKeyEngine", "privateKeyIdentifier", "maxVersion", "minVersion", "passphrase", "pfx", "secureOptions", "secureProtocol", "sessionIdContext" ]; class HttpReceiver { /** @internal */ static DEFAULT_ENDPOINT = "/zoom/events"; eventEmitter; interactiveAuth; /** @internal */ options; server; logger; constructor(options) { if (!options.webhooksSecretToken) { throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = options.logger ?? (() => { const defaultLogger = new ConsoleLogger(); defaultLogger.setLevel(options.logLevel ?? LogLevel.ERROR); return defaultLogger; })(); } canInstall() { return true; } getServerCreator() { return this.hasSecureOptions() ? createServer : createServer$1; } hasEndpoint(pathname) { const { endpoints } = this.options; return Array.isArray(endpoints) ? endpoints.includes(pathname) : endpoints === pathname; } hasSecureOptions() { return Object.keys(this.options).some((option) => secureServerOptionKeys.includes(option)); } init({ eventEmitter, interactiveAuth }) { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } start(port) { if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); } const listenPort = port ?? this.options.port; return new Promise((resolve, reject) => { this.server = this.getServerCreator()(this.options, (req, res) => void (async () => { // `req.headers.host` should be used with care, as clients can manipulate this value. // However, for this use case, the value is completely discarded and only `pathname` // is used, which is why there's no further validation occurring. const { pathname, searchParams } = new URL(req.url ?? "", `http://${req.headers.host ?? "localhost"}`); const { interactiveAuth } = this; this.logger.debug([pathname, searchParams]); // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; if (pathname == installerOptions.installPath) { const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); await (installerOptions.directInstall ? this.writeTemporaryRedirect(res, authUrl) : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(authUrl))); return; } // The user has navigated to the redirect page; init the code if (pathname === installerOptions.redirectUriPath) { const authCode = searchParams.get("code"); const stateCode = searchParams.get("state"); try { if (!authCode || !stateCode) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate()); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); return; } } } // We currently only support a single endpoint, though this will change in the future. if (!this.hasEndpoint(pathname)) { await this.writeResponse(res, StatusCode.NOT_FOUND); return; } // We currently only support POST requests, as that's what Zoom sends. if (req.method !== "post" && req.method !== "POST") { await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); return; } try { const { webhooksSecretToken } = this.options; const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); const processedEvent = request.processEvent(); if (isHashedUrlValidation(processedEvent)) { await this.writeResponse(res, StatusCode.OK, processedEvent); } else { await this.eventEmitter?.emit(processedEvent.event, processedEvent); await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); } } catch (err) { if (isCoreError(err, "CommonHttpRequestError")) { await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); } else { console.error(err); await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { error: "An unknown error occurred. Please try again later." }); } } })()); this.server.on("close", () => (this.server = undefined)); this.server.on("error", (err) => { this.logger.error(err.message); reject(err); }); this.server.listen(listenPort, () => { if (!this.server) { throw new ReceiverInconsistentStateError(); } const { port: listeningPort } = this.server.address(); this.logger.info(`Listening on port ${listeningPort.toString()}`); resolve(this.server); }); }); } stop() { if (!this.server) { throw new ReceiverInconsistentStateError(); } return new Promise((resolve, reject) => { this.server?.close((err) => { if (err) { this.logger.error(err.message); reject(err); } }); this.server = undefined; resolve(); }); } writeTemporaryRedirect(res, location) { return new Promise((resolve) => { res.writeHead(StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } writeResponse(res, statusCode, bodyContent) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); }); }); } } const type = "module"; const name = "@zoom/rivet"; const author = "Zoom Communications, Inc."; const contributors = [ { name: "James Coon", email: "james.coon@zoom.us", url: "https://www.npmjs.com/~jcoon97" }, { name: "Will Ezrine", email: "will.ezrine@zoom.us", url: "https://www.npmjs.com/~wezrine" }, { name: "Tommy Gaessler", email: "tommy.gaessler@zoom.us", url: "https://www.npmjs.com/~tommygaessler" } ]; const packageManager = "pnpm@9.9.0"; const version = "0.2.2"; const scripts = { test: "vitest", "test:coverage": "vitest --coverage", "export": "rollup --config ./rollup.config.mjs", prepare: "husky", lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" }; const devDependencies = { "@eslint/js": "^9.12.0", "@rollup/plugin-commonjs": "^28.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-typescript": "^12.1.0", "@tsconfig/recommended": "^1.0.7", "@tsconfig/strictest": "^2.0.5", "@types/eslint__js": "^8.42.3", "@types/node": "^22.7.5", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", "@vitest/coverage-v8": "2.1.3", dotenv: "^16.4.5", eslint: "^9.12.0", "eslint-plugin-n": "^17.11.1", "eslint-plugin-promise": "^7.1.0", "get-port": "^7.1.0", husky: "^9.1.6", "lint-staged": "^15.2.10", nock: "^13.5.5", prettier: "^3.3.3", "prettier-plugin-organize-imports": "^4.1.0", rollup: "^4.24.0", "rollup-plugin-copy": "^3.5.0", "rollup-plugin-dts": "^6.1.1", semver: "^7.6.3", supertest: "^7.0.0", "ts-node": "^10.9.2", tslib: "^2.7.0", typescript: "^5.6.3", "typescript-eslint": "^8.8.1", vitest: "2.1.3" }; var packageJson = { type: type, name: name, author: author, contributors: contributors, packageManager: packageManager, version: version, scripts: scripts, devDependencies: devDependencies, "lint-staged": { "*": "prettier --ignore-unknown --write", "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ "eslint --fix", "eslint" ] } }; class WebEndpoints { /** @internal */ static DEFAULT_BASE_URL = "https://api.zoom.us/v2"; /** @internal */ static DEFAULT_MIME_TYPE = "application/json"; /** @internal */ static DEFAULT_TIMEOUT = 0; /** @internal */ static GENERIC_ERROR_MESSAGE = "Request was unsuccessful with no further context"; /** @internal */ static TRACKING_ID_HEADER = "x-zm-trackingid"; /** @internal */ options; constructor(options) { this.options = mergeDefaultOptions(options, { baseUrl: WebEndpoints.DEFAULT_BASE_URL, hasCustomBaseUrl: typeof options.baseUrl !== "undefined", timeout: WebEndpoints.DEFAULT_TIMEOUT }); } buildEndpoint({ method, baseUrlOverride, urlPathBuilder, requestMimeType }) { // @ts-expect-error: Some arguments may not be present, but we pass them to makeRequest() anyway. // prettier-ignore // Next AST node is ignored by Prettier, even though it exceed maximum line length, because TypeScript // won't allow ts-expect-error directive on multiple lines (https://github.com/Microsoft/TypeScript/issues/19573). return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { return (`rivet/${packageJson.version} ` + `${basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); } getHeaders(bearerToken, contentType) { return { Accept: "application/json", Authorization: `Bearer ${bearerToken}`, "Content-Type": contentType, "User-Agent": this.buildUserAgent() }; } getRequestBody(args, mimeType) { if (mimeType === "multipart/form-data") { const formData = new FormData(); Object.entries(args).forEach(([key, value]) => { formData.append(key, value); }); return formData; } return args; } isOk(response) { return response.status >= 200 && response.status <= 299; } isZoomResponseError(obj) { return (typeof obj.code !== "undefined" && typeof obj.message !== "undefined"); } async makeRequest(method, baseUrlOverride, url, requestContentType, bodyArgs, queryArgs) { const { auth, baseUrl, doubleEncodeUrl, hasCustomBaseUrl, timeout } = this.options; const bearerToken = await Promise.resolve(auth.getToken()); const urlToSend = doubleEncodeUrl ? encodeURIComponent(encodeURIComponent(url)) : url; const response = await axios({ url: urlToSend, method, baseURL: hasCustomBaseUrl ? baseUrl : (baseUrlOverride ?? baseUrl), headers: this.getHeaders(bearerToken, requestContentType), params: queryArgs, data: bodyArgs && this.getRequestBody(bodyArgs, requestContentType), timeout: timeout, beforeRedirect: (options) => { options.headers = { ...this.getHeaders(bearerToken, requestContentType), ...options.headers }; }, validateStatus: () => true // All responses are valid, not just 2xx }); if (!this.isOk(response)) { const { status: statusCode } = response; if (this.isZoomResponseError(response.data)) { const { code: errorCode, message: errorMessage } = response.data; throw new ApiResponseError(`[${statusCode.toString()}/${errorCode.toString()}]: "${errorMessage}"`); } throw new ApiResponseError(`[${statusCode.toString()}]: ${WebEndpoints.GENERIC_ERROR_MESSAGE}`); } return { data: response.data, statusCode: response.status, trackingId: response.headers[WebEndpoints.TRACKING_ID_HEADER] }; } } class PhoneEndpoints extends WebEndpoints { accounts = { listAccountsZoomPhoneSettings: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/phone/account_settings` }), listAccountsCustomizedOutboundCallerIDPhoneNumbers: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/phone/outbound_caller_id/customized_numbers` }), addPhoneNumbersForAccountsCustomizedOutboundCallerID: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/phone/outbound_caller_id/customized_numbers` }), deletePhoneNumbersForAccountsCustomizedOutboundCallerID: this.buildEndpoint({ method: "DELETE", urlPathBuilder: () => `/phone/outbound_caller_id/customized_numbers` }) }; alerts = { listAlertSettingsWithPagingQuery: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/phone/alert_settings` }), addAlertSetting: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/phone/alert_settings` }), getAlertSettingDetails: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ alertSettingId }) => `/phone/alert_settings/${alertSettingId}` }), deleteAlertSetting: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ alertSettingId }) => `/phone/alert_settings/${alertSettingId}` }), updateAlertSetting: this.buildEndpoint({ method: "PATCH", urlPathBuilder: ({ alertSettingId }) => `/phone/alert_settings/${alertSettingId}` }) }; audioLibrary = { getAudioItem: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ audioId }) => `/phone/audios/${audioId}` }), deleteAudioItem: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ audioId }) => `/phone/audios/${audioId}` }), updateAudioItem: this.buildEndpoint({ method: "PATCH", urlPathBuilder: ({ audioId }) => `/phone/audios/${audioId}` }), listAudioItems: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ userId }) => `/phone/users/${userId}/audios` }), addAudioItemForTextToSpeechConversion: this.buildEndpoint({ method: "POST", urlPathBuilder: ({ userId }) => `/phone/users/${userId}/audios` }), addAudioItems: this.buildEndpoint({ method: "POST", urlPathBuilder: ({ userId }) => `/phone/users/${userId}/audios/batch` }) }; autoReceptionists = { listAutoReceptionists: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/phone/auto_receptionists` }), addAutoReceptionist: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/phone/auto_receptionists` }), getAutoReceptionist: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ autoReceptionistId }) => `/phone/auto_receptionists/${autoReceptionistId}` }), deleteNonPrimaryAutoReceptionist: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ autoReceptionistId }) => `/phone/auto_receptionists/${autoReceptionistId}` }), updateAutoReceptionist: this.buildEndpoint({ method: "PATCH", urlPathBuilder: ({ autoReceptionistId }) => `/phone/auto_receptionists/${autoReceptionistId}` }), assignPhoneNumbers: this.buildEndpoint({ method: "POST", urlPathBuilder: ({ autoReceptionistId }) => `/phone/auto_receptionists/${autoReceptionistId}/phone_numbers` }), unassignAllPhoneNumbers: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ autoReceptionistId }) => `/phone/auto_receptionists/${autoReceptionistId}/phone_numbers` }), unassignPhoneNumber: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ autoReceptionistId, phoneNumberId }) => `/phone/auto_receptionists/${autoReceptionistId}/phone_numbers/${phoneNumberId}` }), getAutoReceptionistPolicy: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ autoReceptionistId }) => `/phone/auto_receptionists/${autoReceptionistId}/policies` }), updateAutoReceptionistPolicy: this.buildEndpoint({ method: "PATCH", urlPathBuilder: ({ autoReceptionistId }) => `/phone/auto_receptionists/${autoReceptionistId}/policies` }), addPolicySubsetting: this.buildEndpoint({ method: "POST", urlPathBuilder: ({ autoReceptionistId, policyType }) => `/phone/auto_receptionists/${autoReceptionistId}/policies/${policyType}` }), deletePolicySubsetting: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ autoReceptionistId, policyType }) => `/phone/auto_receptionists/${autoReceptionistId}/policies/${policyType}` }), updatePolicySubsetting: this.buildEndpoint({ method: "PATCH", urlPathBuilder: ({ autoReceptionistId, policyType }) => `/phone/auto_receptionists/${autoReceptionistId}/policies/${policyType}` }) }; billingAccount = { listBillingAccounts: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/phone/billing_accounts` }), getBillingAccountDetails: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ billingAccountId }) => `/phone/billing_accounts/${billingAccountId}` }) }; blockedList = { listBlockedLists: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/phone/blocked_list` }), createBlockedList: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/phone/blocked_list` }), getBlockedListDetails: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ blockedListId }) => `/phone/blocked_list/${blockedListId}` }), deleteBlockedList: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ blockedListId }) => `/phone/blocked_list/${blockedListId}` }), updateBlockedList: this.buildEndpoint({ method: "PATCH", urlPathBuilder: ({ blockedListId }) => `/phone/blocked_list/${blockedListId}` }) }; callHandling = { getCallHandlingSettings: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ extensionId }) => `/phone/extension/${extensionId}/call_handling/settings` }), addCallHandlingSetting: this.buildEndpoint({ method: "POST", urlPathBuilder: ({ extensionId, settingType }) => `/phone/extension/${extensionId}/call_handling/settings/${settingType}` }), deleteCallHandlingSetting: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ extensionId, settingType }) => `/phone/extension/${extensionId}/call_handling/settings/${settingType}` }), updateCallHandlingSetting: this.buildEndpoint({ method: "PATCH", urlPathBuilder: ({ extensionId, settingType }) => `/phone/extension/${extensionId}/call_handling/settings/${settingType}` }) }; callLogs = { getAccountsCallHistory: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/phone/call_history` }), getCallPath: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ callLogId }) => `/phone/call_history/${callLogId}` }), addClientCodeToCallHistory: this.buildEndpoint({ method: "PATCH", urlPathBuilder: ({ callLogId }) => `/phone/call_history/${callLogId}/client_code` }), getAccountsCallLogs: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/phone/call_logs` }), getCallLogDetails: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ callLogId }) => `/phone/call_logs/${callLogId}` }), addClientCodeToCallLog: this.buildEndpoint({ method: "PUT", urlPathBuilder: ({ callLogId }) => `/phone/call_logs/${callLogId}/client_code` }), getUsersCallHistory: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ userId }) => `/phone/users/${userId}/call_history` }), syncUsersCallHistory: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ userId }) => `/phone/users/${userId}/call_history/sync` }), deleteUsersCallHistory: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ userId, callLogId }) => `/phone/users/${userId}/call_history/${callLogId}` }), getUsersCallLogs: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ userId }) => `/phone/users/${userId}/call_logs` }), syncUsersCallLogs: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ userId }) => `/phone/users/${userId}/call_logs/sync` }), deleteUsersCallLog: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ userId, callLogId }) => `/phone/users/${userId}/call_logs/${callLogId}` }) }; callQueues = { listCallQueues: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/phone/call_queues` }), createCallQueue: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/phone/call_queues` }), getCallQueueDetails: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ callQueueId }) => `/phone/call_queues/${callQueueId}` }), deleteCallQueue: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ callQueueId }) => `/phone/call_queues/${callQueueId}` }), updateCallQueueDetails: this.buildEndpoint({ method: "PATCH", urlPathBuilder: ({ callQueueId }) => `/phone/call_queues/${callQueueId}` }), listCallQueueMembers: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ callQueueId }) => `/phone/call_queues/${callQueueId}/members` }), addMembersToCallQueue: this.buildEndpoint({ method: "POST", urlPathBuilder: ({ callQueueId }) => `/phone/call_queues/${callQueueId}/members` }), unassignAllMembers: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ callQueueId }) => `/phone/call_queues/${callQueueId}/members` }), unassignMember: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ callQueueId, memberId }) => `/phone/call_queues/${callQueueId}/members/${memberId}` }), assignNumbersToCallQueue: this.buildEndpoint({ method: "POST", urlPathBuilder: ({ callQueueId }) => `/phone/call_queues/${callQueueId}/phone_numbers` }), unassignAllPhoneNumbers: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ callQueueId }) => `/phone/call_queues/${callQueueId}/phone_numbers` }), unassignPhoneNumber: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ callQueueId, phoneNumberId }) => `/phone/call_queues/${callQueueId}/phone_numbers/${phoneNumberId}` }), addPolicySettingToCallQueue: this.buildEndpoint({ method: "POST", urlPathBuilder: ({ callQueueId, policyType }) => `/phone/call_queues/${callQueueId}/policies/${policyType}` }), deleteCQPolicySetting: this.buildEndpoint({ method: "DELETE", urlPathBuilder: ({ callQueueId, policyType }) => `/phone/call_queues/${callQueueId}/policies/${policyType}` }), updateCallQueuesPolicySubsetting: this