import { readOptionalString } from './auth.ts'
import type { ZoomMeetingLocator, ZoomUserLocator, ZoomWebinarLocator } from './types.ts'
const DEFAULT_USER_ID = 'me'
/**
* Resolve the Zoom user path segment. Defaults to `me` (OAuth user context;
* Server-to-Server also accepts `me` for the app owner).
*/
export function normalizeUserLocator(params: Record<string, unknown> = {}): ZoomUserLocator {
const userId =
readOptionalString(params.userId) ??
readOptionalString(params.user_id) ??
readOptionalString(params.email) ??
DEFAULT_USER_ID
return { userId: encodePathSegment(userId) }
}
export function normalizeMeetingLocator(params: Record<string, unknown> = {}): ZoomMeetingLocator {
const fromUrl = parseZoomResourceUrl(readOptionalString(params.meetingUrl) ?? readOptionalString(params.url))
const meetingId =
readOptionalString(params.meetingId) ??
readOptionalString(params.meeting_id) ??
(typeof params.id === 'number' ? String(params.id) : undefined) ??
readOptionalString(params.id) ??
fromUrl?.id
if (!meetingId) {
throw new Error(
'A meeting locator is required (meetingId, meeting_id, id, or a zoom.us/j/{id} meetingUrl).',
)
}
const occurrenceId =
readOptionalString(params.occurrenceId) ??
readOptionalString(params.occurrence_id) ??
fromUrl?.occurrenceId
return occurrenceId
? { meetingId: encodePathSegment(meetingId), occurrenceId }
: { meetingId: encodePathSegment(meetingId) }
}
export function normalizeWebinarLocator(params: Record<string, unknown> = {}): ZoomWebinarLocator {
const fromUrl = parseZoomResourceUrl(readOptionalString(params.webinarUrl) ?? readOptionalString(params.url))
const webinarId =
readOptionalString(params.webinarId) ??
readOptionalString(params.webinar_id) ??
(typeof params.id === 'number' ? String(params.id) : undefined) ??
readOptionalString(params.id) ??
fromUrl?.id
if (!webinarId) {
throw new Error(
'A webinar locator is required (webinarId, webinar_id, id, or a zoom.us/w/{id} webinarUrl).',
)
}
const occurrenceId =
readOptionalString(params.occurrenceId) ??
readOptionalString(params.occurrence_id) ??
fromUrl?.occurrenceId
return occurrenceId
? { webinarId: encodePathSegment(webinarId), occurrenceId }
: { webinarId: encodePathSegment(webinarId) }
}
export function parseZoomResourceUrl(value: string | undefined): {
id: string
occurrenceId?: string
} | null {
if (!value) return null
let parsed: URL
try {
parsed = new URL(value)
} catch {
return null
}
if (!isZoomHost(parsed.host)) {
throw new Error(
`Meeting and webinar URLs must be on zoom.us (received ${parsed.host}). Do not pass personal aliases or third-party hosts.`,
)
}
const match = parsed.pathname.match(/\/(?:j|w|webinar|wc)\/(\d+)/i)
if (!match?.[1]) {
throw new Error(
'Could not parse a numeric Zoom meeting or webinar id from that URL. Use /j/{id} or /w/{id}.',
)
}
const occurrenceId = parsed.searchParams.get('occurrence_id') ?? undefined
return occurrenceId ? { id: match[1], occurrenceId } : { id: match[1] }
}
export function encodePathSegment(value: string): string {
const trimmed = value.trim()
if (!trimmed) {
throw new Error('Path segment must be a non-empty string.')
}
if (trimmed.includes('%')) return trimmed
return encodeURIComponent(trimmed)
}
function isZoomHost(host: string): boolean {
const normalized = host.toLowerCase()
return normalized === 'zoom.us' || normalized.endsWith('.zoom.us')
}