Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/zoom

src/core.ts

478 lines · 14.4 KB · TypeScript
import { createAuthenticatedFetch, secretHeaders } from 'kody:runtime'
import {
	DEFAULT_ZOOM_ACCOUNT_ID_SECRET,
	DEFAULT_ZOOM_CLIENT_ID_SECRET,
	DEFAULT_ZOOM_INTEGRATION_NAME,
	DEFAULT_ZOOM_S2S_CLIENT_SECRET,
	ZOOM_ACCOUNT_ID_SETUP_URL,
	ZOOM_CLIENT_ID_SETUP_URL,
	ZOOM_MARKETPLACE_CREATE_URL,
	ZOOM_OAUTH_CONNECT_URL,
	ZOOM_OAUTH_TOKEN_URL,
	ZOOM_S2S_CLIENT_SECRET_SETUP_URL,
	ZOOM_S2S_DOCS_URL,
	accounts,
	buildOauthConnectUrl,
	buildSecretSetupUrl,
	resolveZoomAuth,
	stringifyError,
} from './auth.ts'
import type {
	ZoomAuthInput,
	ZoomCurrentUser,
	ZoomHeaders,
	ZoomPaginateOptions,
	ZoomPaginationResult,
	ZoomQuery,
	ZoomRequestOptions,
	ZoomResolvedAuth,
	ZoomResponse,
} from './types.ts'

export {
	DEFAULT_ZOOM_ACCOUNT_ID_SECRET,
	DEFAULT_ZOOM_API_BASE_URL,
	DEFAULT_ZOOM_API_HOST,
	DEFAULT_ZOOM_CLIENT_ID_SECRET,
	DEFAULT_ZOOM_INTEGRATION_NAME,
	DEFAULT_ZOOM_S2S_CLIENT_SECRET,
	SUGGESTED_ZOOM_OAUTH_SCOPES,
	SUGGESTED_ZOOM_S2S_SCOPES,
	ZOOM_ACCOUNT_ID_SETUP_URL,
	ZOOM_ALLOWED_HOSTS,
	ZOOM_CLIENT_ID_SETUP_URL,
	ZOOM_MARKETPLACE_CREATE_URL,
	ZOOM_OAUTH_AUTHORIZE_URL,
	ZOOM_OAUTH_CONNECT_URL,
	ZOOM_OAUTH_DOCS_URL,
	ZOOM_OAUTH_TOKEN_URL,
	ZOOM_S2S_CLIENT_SECRET_SETUP_URL,
	ZOOM_S2S_DOCS_URL,
	accounts,
	buildOauthConnectUrl,
	buildSecretSetupUrl,
	isConfirmed,
	isDryRun,
	pickAuthInput,
	resolveAuthMode,
	resolveIntegrationName,
	resolveZoomAuth,
} from './auth.ts'

export {
	encodePathSegment,
	normalizeMeetingLocator,
	normalizeUserLocator,
	normalizeWebinarLocator,
	parseZoomResourceUrl,
} from './locators.ts'

const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])

const selectedHeaderNames = [
	'x-ratelimit-category',
	'x-ratelimit-limit',
	'x-ratelimit-remaining',
	'x-ratelimit-retry-after',
	'x-zm-trackingid',
] as const

export class ZoomRequestError<TData = unknown> extends Error {
	readonly response: ZoomResponse<TData>

	constructor(response: ZoomResponse<TData>) {
		super(buildErrorMessage(response))
		this.name = 'ZoomRequestError'
		this.response = response
	}
}

/**
 * Make an authenticated Zoom REST API request.
 *
 * Mutating methods require `dryRun` or `confirm`.
 */
export async function request<TData = unknown>(
	options: ZoomRequestOptions,
): Promise<ZoomResponse<TData>> {
	if (typeof options?.path !== 'string' || options.path.length === 0) {
		throw new Error(
			'Zoom request requires a non-empty string "path" param (for example "/users/me" or "/users/me/meetings").',
		)
	}
	const auth = resolveZoomAuth(options)
	const url = buildUrl(auth.apiBaseUrl, options.path, options.query)
	const method = (options.method ?? (options.body === undefined ? 'GET' : 'POST')).toUpperCase()
	const mutating = isMutatingMethod(method)
	assertMutationGuard(mutating, options.dryRun, options.confirm, `${method} ${url}`)

	if (options.dryRun && mutating) {
		return {
			auth,
			url,
			ok: true,
			status: 0,
			statusText: 'dry-run',
			headers: {},
			data: {
				dryRun: true,
				method,
				path: options.path,
				wouldRequest: true,
			} as TData,
			text: '',
			dryRun: true,
		}
	}

	const headers = new Headers(options.headers)
	headers.set('Accept', headers.get('Accept') ?? 'application/json')
	headers.set('User-Agent', headers.get('User-Agent') ?? 'kody-zoom')

	const init: RequestInit = { method, headers }
	if (options.body !== undefined) {
		if (!headers.has('Content-Type')) {
			headers.set('Content-Type', 'application/json')
		}
		init.body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body)
	}

	const fetchResponse = await zoomFetch(url, init, auth)
	const text = await fetchResponse.text()
	const data = parseResponseBody<TData>(text, fetchResponse.headers)
	const response: ZoomResponse<TData> = {
		auth,
		url,
		ok: fetchResponse.ok,
		status: fetchResponse.status,
		statusText: fetchResponse.statusText,
		headers: collectHeaders(fetchResponse.headers),
		data,
		text,
	}

	if (!response.ok && options.throwOnError) {
		throw new ZoomRequestError(response)
	}

	return response
}

/**
 * Follow Zoom `next_page_token` pagination for list endpoints.
 */
export async function paginate<TItem = unknown>(
	options: ZoomPaginateOptions,
): Promise<ZoomPaginationResult<TItem>> {
	const maxPages = options.maxPages ?? 20
	const items: TItem[] = []
	let nextPageToken: string | null = null
	let pages = 0
	let lastResponse: ZoomResponse<unknown> | null = null

	while (pages < maxPages) {
		const query = {
			...(options.query ?? {}),
			...(nextPageToken ? { next_page_token: nextPageToken } : {}),
		}
		const response = await request<unknown>({
			integrationName: options.integrationName,
			account: options.account,
			auth: options.auth,
			accountIdSecret: options.accountIdSecret,
			clientIdSecret: options.clientIdSecret,
			clientSecretSecret: options.clientSecretSecret,
			path: options.path,
			method: options.method ?? 'GET',
			query,
			headers: options.headers,
			throwOnError: true,
		})

		const extracted = extractPageItems<TItem>(response.data, options.itemsKey)
		items.push(...extracted.items)
		pages += 1
		lastResponse = response
		nextPageToken = extracted.nextPageToken
		if (!nextPageToken) break
	}

	return {
		auth: lastResponse?.auth ?? resolveZoomAuth(options),
		items,
		pages,
		nextPageToken,
		lastResponse,
	}
}

/**
 * Fetch the Zoom user for `userId` (default `me`).
 */
export async function getCurrentUser(
	options: ZoomAuthInput & { userId?: string } = {},
): Promise<ZoomCurrentUser> {
	const userId = typeof options.userId === 'string' && options.userId.trim() ? options.userId.trim() : 'me'
	const auth = resolveZoomAuth(options)
	const response = await request<{
		id?: string
		email?: string
		display_name?: string
		first_name?: string
		last_name?: string
		type?: number
		status?: string
		timezone?: string
	}>({
		...options,
		path: `/users/${encodeURIComponent(userId)}`,
		throwOnError: true,
	})

	if (!response.data) {
		throw new Error('Zoom current-user response did not include a JSON body.')
	}

	return {
		auth,
		id: response.data.id ?? null,
		email: response.data.email ?? null,
		displayName: response.data.display_name ?? null,
		firstName: response.data.first_name ?? null,
		lastName: response.data.last_name ?? null,
		type: response.data.type ?? null,
		status: response.data.status ?? null,
		timezone: response.data.timezone ?? null,
	}
}

async function zoomFetch(url: string, init: RequestInit, auth: ZoomResolvedAuth): Promise<Response> {
	switch (auth.mode) {
		case 'oauth': {
			let authedFetch: typeof fetch
			try {
				authedFetch = await createAuthenticatedFetch(auth.integrationName!)
			} catch (error) {
				throw wrapOauthSetupError(auth.integrationName!, error)
			}
			return await authedFetch(url, init)
		}
		case 's2s': {
			const accessToken = await mintS2sAccessToken(auth)
			const headers = new Headers(init.headers)
			headers.set('Authorization', `Bearer ${accessToken}`)
			return await fetch(url, { ...init, headers })
		}
		default: {
			const exhaustive: never = auth.mode
			throw new Error('Unsupported Zoom auth mode: ' + String(exhaustive))
		}
	}
}

async function mintS2sAccessToken(auth: ZoomResolvedAuth): Promise<string> {
	const accountIdSecret = auth.accountIdSecret ?? DEFAULT_ZOOM_ACCOUNT_ID_SECRET
	const clientIdSecret = auth.clientIdSecret ?? DEFAULT_ZOOM_CLIENT_ID_SECRET
	const clientSecretSecret = auth.clientSecretSecret ?? DEFAULT_ZOOM_S2S_CLIENT_SECRET
	let response: Response
	try {
		response = await fetch(ZOOM_OAUTH_TOKEN_URL, {
			method: 'POST',
			headers: {
				Accept: 'application/json',
				Authorization: secretHeaders.basic(clientIdSecret, clientSecretSecret),
				'Content-Type': 'application/x-www-form-urlencoded',
			},
			body: new URLSearchParams({
				grant_type: 'account_credentials',
				account_id: `{{secret:${accountIdSecret}|scope=user}}`,
			}),
		})
	} catch (error) {
		throw wrapS2sSetupError(auth, error)
	}

	const payload = (await response.json().catch(() => null)) as { access_token?: unknown } | null
	if (!response.ok || !payload || typeof payload.access_token !== 'string') {
		throw wrapS2sSetupError(
			auth,
			new Error(
				`token endpoint returned ${response.status}` +
					(payload && typeof payload === 'object' && 'error' in payload
						? `: ${String((payload as { error?: unknown }).error)}`
						: ''),
			),
		)
	}
	return payload.access_token
}

function wrapOauthSetupError(integrationName: string, error: unknown): Error {
	const connectUrl = buildOauthConnectUrl({ provider: integrationName })
	return new Error(
		`Zoom OAuth integration "${integrationName}" is not connected or cannot be used (${stringifyError(error)}). ` +
			`There is no built-in Zoom OAuth app. Create a General OAuth app at ${ZOOM_MARKETPLACE_CREATE_URL} ` +
			`with redirect URI https://kody.codes/connect/oauth, then connect at ${connectUrl}. ` +
			`For Server-to-Server instead, save ${DEFAULT_ZOOM_ACCOUNT_ID_SECRET}, ${DEFAULT_ZOOM_CLIENT_ID_SECRET}, ` +
			`and ${DEFAULT_ZOOM_S2S_CLIENT_SECRET} and pass auth: "s2s". See ${ZOOM_S2S_DOCS_URL}.`,
	)
}

function wrapS2sSetupError(auth: ZoomResolvedAuth, error: unknown): Error {
	const accountIdSecret = auth.accountIdSecret ?? DEFAULT_ZOOM_ACCOUNT_ID_SECRET
	const clientIdSecret = auth.clientIdSecret ?? DEFAULT_ZOOM_CLIENT_ID_SECRET
	const clientSecretSecret = auth.clientSecretSecret ?? DEFAULT_ZOOM_S2S_CLIENT_SECRET
	return new Error(
		`Zoom Server-to-Server credentials could not mint an access token (${stringifyError(error)}). ` +
			`Save ${accountIdSecret} at ${buildSecretSetupUrl(accountIdSecret, 'Zoom Server-to-Server account ID')}, ` +
			`${clientIdSecret} at ${buildSecretSetupUrl(clientIdSecret, 'Zoom Server-to-Server client ID')}, ` +
			`and ${clientSecretSecret} at ${buildSecretSetupUrl(clientSecretSecret, 'Zoom Server-to-Server client secret')}. ` +
			`Approve hosts api.zoom.us and zoom.us. Never paste secrets into chat. Docs: ${ZOOM_S2S_DOCS_URL}.`,
	)
}

function assertMutationGuard(
	mutating: boolean,
	dryRun: boolean | undefined,
	confirm: boolean | undefined,
	action: string,
): void {
	if (!mutating) return
	if (dryRun === true) return
	if (confirm === true) return
	throw new Error(
		`Zoom mutation "${action}" requires dryRun: true (preview, no Zoom write) or confirm: true (live write).`,
	)
}

function isMutatingMethod(method: string): boolean {
	return !SAFE_HTTP_METHODS.has(method)
}

function buildUrl(apiBaseUrl: string, path: string, query?: ZoomQuery): string {
	let url = normalizeApiPath(apiBaseUrl, path)
	if (!query) return url

	const searchParams = new URLSearchParams()
	for (const [key, value] of Object.entries(query)) {
		if (value === null || value === undefined) continue
		searchParams.set(key, String(value))
	}

	const queryString = searchParams.toString()
	if (!queryString) return url

	url += url.includes('?') ? '&' : '?'
	return url + queryString
}

function normalizeApiPath(apiBaseUrl: string, path: string): string {
	if (path.startsWith(apiBaseUrl + '/')) return path
	if (path.startsWith('http://') || path.startsWith('https://')) {
		const host = new URL(path).host
		const allowedHost = new URL(apiBaseUrl).host
		if (host !== allowedHost) {
			throw new Error(
				`Zoom helpers only accept ${allowedHost} URLs or API paths for this apiBaseUrl.`,
			)
		}
		return path
	}
	return apiBaseUrl + (path.startsWith('/') ? path : '/' + path)
}

function parseResponseBody<TData>(text: string, headers: Headers): TData | null {
	if (!text) return null
	const contentType = headers.get('content-type') ?? ''
	if (contentType.includes('json') || text.startsWith('{') || text.startsWith('[')) {
		return JSON.parse(text) as TData
	}
	return text as TData
}

function collectHeaders(headers: Headers): ZoomHeaders {
	const collected: ZoomHeaders = {}
	for (const name of selectedHeaderNames) {
		const value = headers.get(name)
		if (value) collected[name] = value
	}
	return collected
}

function extractPageItems<TItem>(
	data: unknown,
	itemsKey?: string,
): { items: TItem[]; nextPageToken: string | null } {
	if (Array.isArray(data)) {
		return { items: data as TItem[], nextPageToken: null }
	}
	if (!data || typeof data !== 'object') {
		return { items: [], nextPageToken: null }
	}
	const record = data as Record<string, unknown>
	const nextPageToken = typeof record.next_page_token === 'string' && record.next_page_token
		? record.next_page_token
		: null
	if (itemsKey && Array.isArray(record[itemsKey])) {
		return { items: record[itemsKey] as TItem[], nextPageToken }
	}
	for (const key of ['meetings', 'users', 'webinars', 'recordings', 'recording_files']) {
		if (Array.isArray(record[key])) {
			return { items: record[key] as TItem[], nextPageToken }
		}
	}
	throw new Error(
		'Zoom pagination requires a JSON array or an object with meetings/users/webinars/recordings.',
	)
}

function buildErrorMessage(response: ZoomResponse<unknown>): string {
	const message = extractZoomMessage(response.data)
	const nextStep = nextSetupStep(response.auth)
	if (response.status === 401 || response.status === 403) {
		return (
			`Zoom request failed with ${response.status}` +
			(message ? `: ${message}` : '') +
			`. ${nextStep}`
		)
	}
	return message
		? 'Zoom request failed with ' + response.status + ': ' + message
		: 'Zoom request failed with ' + response.status + ' ' + response.statusText
}

function nextSetupStep(auth: ZoomResolvedAuth): string {
	switch (auth.mode) {
		case 'oauth': {
			const provider = auth.integrationName ?? DEFAULT_ZOOM_INTEGRATION_NAME
			const connectUrl = buildOauthConnectUrl({ provider })
			return (
				`Next setup step: reconnect at ${connectUrl} and confirm the Zoom app still has the needed scopes. ` +
				`Default connect URL: ${ZOOM_OAUTH_CONNECT_URL}.`
			)
		}
		case 's2s': {
			return (
				`Next setup step: confirm the Server-to-Server app is activated with matching scopes, then update ` +
				`${auth.accountIdSecret ?? DEFAULT_ZOOM_ACCOUNT_ID_SECRET} / ` +
				`${auth.clientIdSecret ?? DEFAULT_ZOOM_CLIENT_ID_SECRET} / ` +
				`${auth.clientSecretSecret ?? DEFAULT_ZOOM_S2S_CLIENT_SECRET} ` +
				`(${ZOOM_ACCOUNT_ID_SETUP_URL}, ${ZOOM_CLIENT_ID_SETUP_URL}, ${ZOOM_S2S_CLIENT_SECRET_SETUP_URL}).`
			)
		}
		default: {
			const exhaustive: never = auth.mode
			throw new Error('Unsupported Zoom auth mode: ' + String(exhaustive))
		}
	}
}

function extractZoomMessage(data: unknown): string | null {
	if (!data || typeof data !== 'object') return null
	const record = data as { message?: unknown; error?: unknown; code?: unknown }
	if (typeof record.message === 'string') {
		return typeof record.code === 'number' ? `${record.code} ${record.message}` : record.message
	}
	if (typeof record.error === 'string') return record.error
	return null
}