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 = {}): 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 = {}): 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 = {}): 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') }