import { createAuthenticatedFetch, secretHeaders } from 'kody:runtime' import { DEFAULT_ZOOM_CLIENT_ID_SECRET, DEFAULT_ZOOM_INTEGRATION_NAME, DEFAULT_ZOOM_S2S_CLIENT_SECRET, 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_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_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 extends Error { readonly response: ZoomResponse constructor(response: ZoomResponse) { 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( options: ZoomRequestOptions, ): Promise> { 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(text, fetchResponse.headers) const response: ZoomResponse = { 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( options: ZoomPaginateOptions, ): Promise> { const maxPages = options.maxPages ?? 20 const items: TItem[] = [] let nextPageToken: string | null = null let pages = 0 let lastResponse: ZoomResponse | null = null while (pages < maxPages) { const query = { ...(options.query ?? {}), ...(nextPageToken ? { next_page_token: nextPageToken } : {}), } const response = await request({ integrationName: options.integrationName, account: options.account, auth: options.auth, accountId: options.accountId, clientIdSecret: options.clientIdSecret, clientSecretSecret: options.clientSecretSecret, path: options.path, method: options.method ?? 'GET', query, headers: options.headers, throwOnError: true, }) const extracted = extractPageItems(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 { 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 { 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 { const clientIdSecret = auth.clientIdSecret ?? DEFAULT_ZOOM_CLIENT_ID_SECRET const clientSecretSecret = auth.clientSecretSecret ?? DEFAULT_ZOOM_S2S_CLIENT_SECRET if (!auth.accountId) { throw wrapS2sSetupError(auth, new Error('accountId is required for Server-to-Server auth')) } // Secrets only travel in the Basic Authorization header. Zoom requires the // (non-secret) account ID in the form body or query, so it is sent as-is. let response: Response try { response = await fetch(ZOOM_OAUTH_TOKEN_URL, { method: 'POST', headers: { Accept: 'application/json', Authorization: secretHeaders.basic({ usernameSecret: clientIdSecret, passwordSecret: clientSecretSecret, scope: 'user', }), 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'account_credentials', account_id: auth.accountId, }).toString(), }) } 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_CLIENT_ID_SECRET} and ${DEFAULT_ZOOM_S2S_CLIENT_SECRET}, ` + `then pass auth: "s2s" with accountId (the plain Zoom account ID). See ${ZOOM_S2S_DOCS_URL}.`, ) } function wrapS2sSetupError(auth: ZoomResolvedAuth, error: unknown): Error { 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)}). ` + `Pass accountId (the plain Account ID from the app's App Credentials page). ` + `Save ${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(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( 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 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): 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 and accountId ` + `matches it, then update ` + `${auth.clientIdSecret ?? DEFAULT_ZOOM_CLIENT_ID_SECRET} / ` + `${auth.clientSecretSecret ?? DEFAULT_ZOOM_S2S_CLIENT_SECRET} ` + `(${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 }