import type { ZoomAuthInput, ZoomAuthLaneInfo, ZoomAuthMode, ZoomResolvedAuth } from './types.ts' export const DEFAULT_ZOOM_API_BASE_URL = 'https://api.zoom.us/v2' export const DEFAULT_ZOOM_API_HOST = 'api.zoom.us' export const ZOOM_TOKEN_HOST = 'zoom.us' export const DEFAULT_ZOOM_INTEGRATION_NAME = 'zoom' export const DEFAULT_ZOOM_CLIENT_ID_SECRET = 'zoomClientId' export const DEFAULT_ZOOM_S2S_CLIENT_SECRET = 'zoomS2sClientSecret' export const ZOOM_OAUTH_AUTHORIZE_URL = 'https://zoom.us/oauth/authorize' export const ZOOM_OAUTH_TOKEN_URL = 'https://zoom.us/oauth/token' export const ZOOM_MARKETPLACE_CREATE_URL = 'https://marketplace.zoom.us/develop/create' export const ZOOM_S2S_DOCS_URL = 'https://developers.zoom.us/docs/internal-apps/s2s-oauth/' export const ZOOM_OAUTH_DOCS_URL = 'https://developers.zoom.us/docs/integrations/oauth/' export const SUGGESTED_ZOOM_OAUTH_SCOPES = [ 'user:read:user', 'meeting:read:list_meetings', 'meeting:read:meeting', 'meeting:write:meeting', 'meeting:update:meeting', 'meeting:delete:meeting', 'cloud_recording:read:list_user_recordings', 'cloud_recording:read:recording', 'webinar:read:list_webinars', 'webinar:read:webinar', 'webinar:write:webinar', 'webinar:update:webinar', 'webinar:delete:webinar', ] as const export const SUGGESTED_ZOOM_S2S_SCOPES = [ 'user:read:user:admin', 'user:read:list_users:admin', 'meeting:read:list_meetings:admin', 'meeting:read:meeting:admin', 'meeting:write:meeting:admin', 'meeting:update:meeting:admin', 'meeting:delete:meeting:admin', 'cloud_recording:read:list_user_recordings:admin', 'cloud_recording:read:recording:admin', 'webinar:read:list_webinars:admin', 'webinar:read:webinar:admin', 'webinar:write:webinar:admin', 'webinar:update:webinar:admin', 'webinar:delete:webinar:admin', ] as const export const ZOOM_ALLOWED_HOSTS = `${DEFAULT_ZOOM_API_HOST},${ZOOM_TOKEN_HOST}` const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/ const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9_-]{6,64}$/ const CLIENT_ID_SECRET_PATTERN = /^zoomClientId(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/ const S2S_CLIENT_SECRET_PATTERN = /^zoomS2sClientSecret(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/ export const ZOOM_OAUTH_CONNECT_URL = buildOauthConnectUrl({ provider: DEFAULT_ZOOM_INTEGRATION_NAME, }) export const ZOOM_CLIENT_ID_SETUP_URL = buildSecretSetupUrl( DEFAULT_ZOOM_CLIENT_ID_SECRET, 'Zoom Server-to-Server client ID', ) export const ZOOM_S2S_CLIENT_SECRET_SETUP_URL = buildSecretSetupUrl( DEFAULT_ZOOM_S2S_CLIENT_SECRET, 'Zoom Server-to-Server client secret', ) const zoomAuthLanes = [ { lane: 'oauth', default: true, integrationName: DEFAULT_ZOOM_INTEGRATION_NAME, connectUrl: ZOOM_OAUTH_CONNECT_URL, useWhen: 'Bring-your-own Zoom OAuth app. Pass a distinct integrationName (zoom-work, …) for each extra connected identity.', avoidWhen: 'You only have Server-to-Server account credentials and have not connected user OAuth.', mutationGuidance: 'Meeting and webinar create/update/delete require dryRun: true to preview, then confirm: true to apply.', }, { lane: 's2s', default: false, clientIdSecret: DEFAULT_ZOOM_CLIENT_ID_SECRET, clientSecretSecret: DEFAULT_ZOOM_S2S_CLIENT_SECRET, clientIdSetupUrl: ZOOM_CLIENT_ID_SETUP_URL, clientSecretSetupUrl: ZOOM_S2S_CLIENT_SECRET_SETUP_URL, useWhen: 'Server-to-Server OAuth app credentials. Pass accountId (the plain Zoom account ID from the app\'s App Credentials page, not a secret) on every S2S call; client ID and client secret stay saved secrets.', avoidWhen: 'A saved Zoom user OAuth integration already covers the work.', mutationGuidance: 'Meeting and webinar create/update/delete require dryRun: true to preview, then confirm: true to apply.', }, ] as const satisfies readonly ZoomAuthLaneInfo[] /** * Return the supported Zoom auth lanes and selection guidance. */ export function accounts(): readonly ZoomAuthLaneInfo[] { return zoomAuthLanes } /** * Resolve OAuth vs Server-to-Server credentials and the Zoom API base URL. */ export function resolveZoomAuth(input: ZoomAuthInput = {}): ZoomResolvedAuth { const mode = resolveAuthMode(input) switch (mode) { case 's2s': { const accountId = resolveAccountId(input.accountId) const clientIdSecret = resolveNamedSecret( input.clientIdSecret, DEFAULT_ZOOM_CLIENT_ID_SECRET, CLIENT_ID_SECRET_PATTERN, 'clientIdSecret', input.account, ) const clientSecretSecret = resolveNamedSecret( input.clientSecretSecret, DEFAULT_ZOOM_S2S_CLIENT_SECRET, S2S_CLIENT_SECRET_PATTERN, 'clientSecretSecret', input.account, ) return { mode: 's2s', integrationName: null, accountId, clientIdSecret, clientSecretSecret, label: clientIdSecret, apiBaseUrl: DEFAULT_ZOOM_API_BASE_URL, apiHost: DEFAULT_ZOOM_API_HOST, } } case 'oauth': { const integrationName = resolveIntegrationName(input) return { mode: 'oauth', integrationName, accountId: null, clientIdSecret: null, clientSecretSecret: null, label: integrationName, apiBaseUrl: DEFAULT_ZOOM_API_BASE_URL, apiHost: DEFAULT_ZOOM_API_HOST, } } default: { const exhaustive: never = mode throw new Error('Unsupported Zoom auth mode: ' + String(exhaustive)) } } } export function resolveAuthMode(input: ZoomAuthInput = {}): ZoomAuthMode { const explicit = trimToUndefined(input.auth) if (explicit === 'oauth' || explicit === 's2s') return explicit if (explicit) { throw new Error('auth must be "oauth" or "s2s".') } if ( trimToUndefined(input.accountId) || trimToUndefined(input.clientIdSecret) || trimToUndefined(input.clientSecretSecret) ) { return 's2s' } return 'oauth' } export function resolveIntegrationName(input: ZoomAuthInput = {}): string { const explicit = trimToUndefined(input.integrationName) if (explicit) return explicit const account = trimToUndefined(input.account) if (!account || account === 'default') return DEFAULT_ZOOM_INTEGRATION_NAME if (account === DEFAULT_ZOOM_INTEGRATION_NAME || account.startsWith('zoom-')) { return account } if (!ACCOUNT_LABEL_PATTERN.test(account)) { throw new Error( 'account must be a short label such as "work" (letters, numbers, _ or -), or a full zoom-* name.', ) } return `${DEFAULT_ZOOM_INTEGRATION_NAME}-${account}` } export function resolveAccountLabel(account: string | undefined): string | null { const trimmed = trimToUndefined(account) if (!trimmed || trimmed === 'default' || trimmed === DEFAULT_ZOOM_INTEGRATION_NAME) { return null } if (trimmed.startsWith('zoom-')) return trimmed.slice('zoom-'.length) if (!ACCOUNT_LABEL_PATTERN.test(trimmed)) { throw new Error( 'account must be a short label such as "work" (letters, numbers, _ or -), or a full zoom-* name.', ) } return trimmed } export function buildOauthConnectUrl(options: { provider?: string scopes?: readonly string[] } = {}): string { const provider = trimToUndefined(options.provider) ?? DEFAULT_ZOOM_INTEGRATION_NAME const scopes = options.scopes ?? SUGGESTED_ZOOM_OAUTH_SCOPES const params = new URLSearchParams({ provider, authorizeUrl: ZOOM_OAUTH_AUTHORIZE_URL, tokenUrl: ZOOM_OAUTH_TOKEN_URL, flow: 'confidential', scopes: scopes.join(' '), allowedHosts: ZOOM_ALLOWED_HOSTS, apiBaseUrl: DEFAULT_ZOOM_API_BASE_URL, dashboardUrl: ZOOM_MARKETPLACE_CREATE_URL, }) return `https://kody.codes/connect/oauth?${params.toString()}` } export function buildSecretSetupUrl(secretName: string, description: string): string { const params = new URLSearchParams({ name: secretName, description, allowedHosts: ZOOM_ALLOWED_HOSTS, scope: 'user', }) return `https://kody.codes/account/secrets/new?${params.toString()}` } export function pickAuthInput(params: Record): ZoomAuthInput { const auth = readOptionalString(params.auth) return { integrationName: readOptionalString(params.integrationName), account: readOptionalString(params.account), auth: auth === 'oauth' || auth === 's2s' ? auth : undefined, accountId: readOptionalString(params.accountId), clientIdSecret: readOptionalString(params.clientIdSecret), clientSecretSecret: readOptionalString(params.clientSecretSecret), } } export function isDryRun(params: Record): boolean { return params.dryRun === true } export function isConfirmed(params: Record): boolean { return params.confirm === true } export function trimToUndefined(value: string | undefined): string | undefined { if (typeof value !== 'string') return undefined const trimmed = value.trim() return trimmed.length > 0 ? trimmed : undefined } export function readOptionalString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined } export function stringifyError(error: unknown): string { if (error instanceof Error) return error.message return String(error) } /** * Zoom's Server-to-Server token exchange only accepts the account ID in the * query string or form body, and Kody substitutes secrets only into request * headers. The account ID is a plain identifier (it grants nothing without the * client secret), so S2S callers pass it as a typed input. */ function resolveAccountId(value: string | undefined): string { const accountId = trimToUndefined(value) if (!accountId) { throw new Error( 'Zoom Server-to-Server calls require accountId: the plain Account ID from your Server-to-Server app\'s ' + `App Credentials page (${ZOOM_MARKETPLACE_CREATE_URL}). It is not a secret; pass it on each S2S call.`, ) } if (!ACCOUNT_ID_PATTERN.test(accountId)) { throw new Error('accountId must be the Zoom account ID (letters, numbers, _ or -).') } return accountId } function resolveNamedSecret( override: string | undefined, base: string, pattern: RegExp, label: string, account: string | undefined, ): string { const explicit = trimToUndefined(override) if (explicit) { if (!pattern.test(explicit)) { throw new Error( `${label} must be ${base} or ${base}-. Got ${explicit}.`, ) } return explicit } const suffix = resolveAccountLabel(account) return suffix ? `${base}-${suffix}` : base }