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_ACCOUNT_ID_SECRET = 'zoomAccountId'
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_SECRET_PATTERN = /^zoomAccountId(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
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_ACCOUNT_ID_SETUP_URL = buildSecretSetupUrl(
DEFAULT_ZOOM_ACCOUNT_ID_SECRET,
'Zoom Server-to-Server account ID',
)
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,
accountIdSecret: DEFAULT_ZOOM_ACCOUNT_ID_SECRET,
clientIdSecret: DEFAULT_ZOOM_CLIENT_ID_SECRET,
clientSecretSecret: DEFAULT_ZOOM_S2S_CLIENT_SECRET,
accountIdSetupUrl: ZOOM_ACCOUNT_ID_SETUP_URL,
clientIdSetupUrl: ZOOM_CLIENT_ID_SETUP_URL,
clientSecretSetupUrl: ZOOM_S2S_CLIENT_SECRET_SETUP_URL,
useWhen:
'Server-to-Server OAuth app credentials (account ID, client ID, client secret). Pass auth: "s2s" or any S2S secret override.',
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 accountIdSecret = resolveNamedSecret(
input.accountIdSecret,
DEFAULT_ZOOM_ACCOUNT_ID_SECRET,
ACCOUNT_ID_SECRET_PATTERN,
'accountIdSecret',
input.account,
)
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,
accountIdSecret,
clientIdSecret,
clientSecretSecret,
label: accountIdSecret,
apiBaseUrl: DEFAULT_ZOOM_API_BASE_URL,
apiHost: DEFAULT_ZOOM_API_HOST,
}
}
case 'oauth': {
const integrationName = resolveIntegrationName(input)
return {
mode: 'oauth',
integrationName,
accountIdSecret: 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.accountIdSecret) ||
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<string, unknown>): ZoomAuthInput {
const auth = readOptionalString(params.auth)
return {
integrationName: readOptionalString(params.integrationName),
account: readOptionalString(params.account),
auth: auth === 'oauth' || auth === 's2s' ? auth : undefined,
accountIdSecret: readOptionalString(params.accountIdSecret),
clientIdSecret: readOptionalString(params.clientIdSecret),
clientSecretSecret: readOptionalString(params.clientSecretSecret),
}
}
export function isDryRun(params: Record<string, unknown>): boolean {
return params.dryRun === true
}
export function isConfirmed(params: Record<string, unknown>): 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)
}
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}-<account>. Got ${explicit}.`,
)
}
return explicit
}
const suffix = resolveAccountLabel(account)
return suffix ? `${base}-${suffix}` : base
}