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
}