Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/zendesk

src/setup.ts

374 lines · 12.5 KB · TypeScript
import type {
	DryRunResult,
	JsonRecord,
	ZendeskAuthInput,
	ZendeskAuthMode,
} from './types.ts'
import { optionalString, requireString } from './types.ts'

export const ZENDESK_CALLBACK_URL = 'https://kody.codes/connect/oauth'
export const ZENDESK_OAUTH_DOCS_URL =
	'https://developer.zendesk.com/api-reference/ticketing/oauth/grant_type_tokens/'
export const ZENDESK_API_TOKEN_DOCS_URL =
	'https://developer.zendesk.com/api-reference/introduction/security-and-auth/'
export const ZENDESK_API_DOCS_URL = 'https://developer.zendesk.com/api-reference/ticketing/tickets/tickets/'
export const ZENDESK_ADMIN_OAUTH_PATH = '/admin/apps-integrations/apis/oauth_clients'
export const ZENDESK_ADMIN_API_TOKEN_PATH = '/admin/apps-integrations/apis/zendesk-api/settings'
export const DEFAULT_INTEGRATION_NAME = 'zendesk'
export const DEFAULT_TOKEN_SECRET = 'zendeskApiToken'
export const DEFAULT_USERNAME_SECRET = 'zendeskApiUsername'
export const DEFAULT_OAUTH_SCOPES = 'read write tickets:read tickets:write users:read users:write hc:read hc:write'
export const SUBDOMAIN_PLACEHOLDER = 'your-subdomain'

const SUBDOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i

export const ZENDESK_SCOPES = {
	read: 'GET tickets, users, and Help Center articles.',
	write: 'Create or update tickets, users, and articles.',
	'tickets:read': 'List, get, and search tickets and comments.',
	'tickets:write': 'Create or update tickets and add comments.',
	'users:read': 'List, get, and search users, including /users/me.',
	'users:write': 'Create or update users.',
	'hc:read': 'List, get, and search Help Center articles.',
	'hc:write': 'Create or update Help Center articles.',
} as const

export type ZendeskOperation =
	| 'me.read'
	| 'tickets.read'
	| 'tickets.write'
	| 'users.read'
	| 'users.write'
	| 'articles.read'
	| 'articles.write'
	| 'unknownRead'
	| 'unknownMutation'

export function normalizeSubdomain(value: string, label = 'subdomain'): string {
	let raw = requireString(value, label)
	raw = raw.replace(/^https?:\/\//i, '')
	raw = raw.split('/')[0] ?? raw
	raw = raw.replace(/\.zendesk\.com$/i, '')
	if (!SUBDOMAIN_RE.test(raw)) {
		throw new Error(
			`${label} must be a Zendesk Support subdomain such as "acme" or "acme.zendesk.com". Pass it on the call or store it with ./configure. Do not hard-code another account's subdomain.`,
		)
	}
	return raw.toLowerCase()
}

export function optionalSubdomain(value: unknown, label = 'subdomain'): string | undefined {
	const raw = optionalString(value, label)
	if (!raw) return undefined
	return normalizeSubdomain(raw, label)
}

export function zendeskHost(subdomain: string): string {
	return `${normalizeSubdomain(subdomain)}.zendesk.com`
}

export function zendeskOrigin(subdomain: string): string {
	return `https://${zendeskHost(subdomain)}`
}

export function resolveApiBaseUrl(subdomain: string): string {
	return `${zendeskOrigin(subdomain)}/api/v2`
}

export function resolveAuthorizeUrl(subdomain: string): string {
	return `${zendeskOrigin(subdomain)}/oauth/authorizations/new`
}

export function resolveTokenUrl(subdomain: string): string {
	return `${zendeskOrigin(subdomain)}/oauth/tokens`
}

export function resolveIntegrationName(input: ZendeskAuthInput = {}): string {
	const explicit =
		optionalString(input.integrationName, 'integrationName') ??
		optionalString(input.integration, 'integration')
	if (explicit) return explicit

	const account = optionalString(input.account, 'account')
	if (!account || account === 'default' || account === 'zendesk') {
		return DEFAULT_INTEGRATION_NAME
	}
	if (account.startsWith('zendesk-')) return account
	return `zendesk-${account}`
}

export function resolveSecretName(input: ZendeskAuthInput = {}): string {
	const explicit = optionalString(input.secretName, 'secretName')
	if (explicit) return explicit
	return suffixedSecret(DEFAULT_TOKEN_SECRET, resolveIntegrationName(input))
}

export function resolveUsernameSecretName(input: ZendeskAuthInput = {}): string {
	const explicit = optionalString(input.usernameSecretName, 'usernameSecretName')
	if (explicit) return explicit
	return suffixedSecret(DEFAULT_USERNAME_SECRET, resolveIntegrationName(input))
}

function suffixedSecret(base: string, integrationName: string): string {
	if (integrationName === DEFAULT_INTEGRATION_NAME) return base
	const suffix = integrationName.startsWith('zendesk-')
		? integrationName.slice('zendesk-'.length)
		: integrationName
	return `${base}-${suffix}`
}

export function oauthConnectUrl(
	provider: string = DEFAULT_INTEGRATION_NAME,
	subdomain: string = SUBDOMAIN_PLACEHOLDER,
): string {
	const name = requireString(provider, 'provider')
	const host = zendeskHost(subdomain)
	const params = new URLSearchParams({
		provider: name,
		authorizeUrl: resolveAuthorizeUrl(subdomain),
		tokenUrl: resolveTokenUrl(subdomain),
		apiBaseUrl: resolveApiBaseUrl(subdomain),
		scopes: DEFAULT_OAUTH_SCOPES,
		flow: 'confidential',
		pkce: 'false',
		allowedHosts: host,
		dashboardUrl: `${zendeskOrigin(subdomain)}${ZENDESK_ADMIN_OAUTH_PATH}`,
	})
	return `https://kody.codes/connect/oauth?${params.toString()}`
}

export function apiTokenSetupUrl(
	secretName: string = DEFAULT_TOKEN_SECRET,
	subdomain: string = SUBDOMAIN_PLACEHOLDER,
): string {
	const name = requireString(secretName, 'secretName')
	const params = new URLSearchParams({
		name,
		description: 'Zendesk API token from Admin Center → Zendesk API',
		allowedHosts: zendeskHost(subdomain),
		scope: 'user',
	})
	return `https://kody.codes/account/secrets/new?${params.toString()}`
}

export function apiUsernameSetupUrl(
	secretName: string = DEFAULT_USERNAME_SECRET,
	subdomain: string = SUBDOMAIN_PLACEHOLDER,
	email?: string,
): string {
	const name = requireString(secretName, 'secretName')
	const hint = email ? `${email}/token` : 'your-agent-email/token'
	const params = new URLSearchParams({
		name,
		description: `Zendesk API username (${hint})`,
		allowedHosts: zendeskHost(subdomain),
		scope: 'user',
	})
	return `https://kody.codes/account/secrets/new?${params.toString()}`
}

export function reconnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string {
	return `https://kody.codes/connect/oauth?provider=${encodeURIComponent(provider)}`
}

export function missingSubdomainMessage(input: ZendeskAuthInput = {}): string {
	const integrationName = resolveIntegrationName(input)
	return [
		'Zendesk subdomain is missing.',
		'Pass subdomain: "acme" (or "acme.zendesk.com") on the call, or store it with ./configure.',
		'Do not reuse another account\'s subdomain.',
		`Example OAuth setup after you substitute your subdomain: ${oauthConnectUrl(integrationName, SUBDOMAIN_PLACEHOLDER)}`,
	].join(' ')
}

export function missingCredentialsMessage(options: {
	integrationName: string
	secretName: string
	usernameSecretName: string
	subdomain?: string
	email?: string
}): string {
	const subdomain = options.subdomain ?? SUBDOMAIN_PLACEHOLDER
	return [
		'Zendesk credentials are missing.',
		'API token (fastest for one account): create a token in Admin Center → Apps and integrations → APIs → Zendesk API.',
		`Save the username as ${options.usernameSecretName} with value {agent-email}/token (do not paste the value in chat):`,
		apiUsernameSetupUrl(options.usernameSecretName, subdomain, options.email),
		`Then save the token as ${options.secretName}:`,
		apiTokenSetupUrl(options.secretName, subdomain),
		ZENDESK_API_TOKEN_DOCS_URL,
		'OAuth (refreshable, scoped): create a confidential OAuth client,',
		`register redirect URI ${ZENDESK_CALLBACK_URL}, then connect:`,
		oauthConnectUrl(options.integrationName, subdomain),
		ZENDESK_OAUTH_DOCS_URL,
		`Required API host: ${zendeskHost(subdomain)}.`,
	].join(' ')
}

export function permissionForOperation(operation: ZendeskOperation): string {
	switch (operation) {
		case 'me.read':
			return 'users:read'
		case 'tickets.read':
			return 'tickets:read'
		case 'tickets.write':
			return 'tickets:write'
		case 'users.read':
			return 'users:read'
		case 'users.write':
			return 'users:write'
		case 'articles.read':
			return 'hc:read'
		case 'articles.write':
			return 'hc:write'
		case 'unknownRead':
			return 'read'
		case 'unknownMutation':
			return 'write'
		default: {
			const exhaustive: never = operation
			throw new Error(`Unsupported Zendesk operation: ${String(exhaustive)}`)
		}
	}
}

export function nextStepForOperation(
	operation: ZendeskOperation,
	options: {
		authMode: ZendeskAuthMode
		integrationName: string
		secretName: string
		usernameSecretName: string
		subdomain: string
		email?: string
	},
): string {
	const permission = permissionForOperation(operation)
	switch (options.authMode) {
		case 'oauth':
			return [
				`Reconnect the "${options.integrationName}" OAuth integration and enable "${permission}" (or read/write).`,
				reconnectUrl(options.integrationName),
				`Full BYO connect URL: ${oauthConnectUrl(options.integrationName, options.subdomain)}`,
				`Scope reference: ${ZENDESK_OAUTH_DOCS_URL}`,
			].join(' ')
		case 'apiToken':
			return [
				`API tokens inherit the agent role for ${options.usernameSecretName}. Confirm that user can ${permission}.`,
				ZENDESK_API_TOKEN_DOCS_URL,
				apiUsernameSetupUrl(options.usernameSecretName, options.subdomain, options.email),
				apiTokenSetupUrl(options.secretName, options.subdomain),
			].join(' ')
		default: {
			const exhaustive: never = options.authMode
			throw new Error(`Unsupported Zendesk auth mode: ${String(exhaustive)}`)
		}
	}
}

export function isMutatingMethod(method: string): boolean {
	const normalized = method.toUpperCase()
	return (
		normalized === 'POST' ||
		normalized === 'PUT' ||
		normalized === 'PATCH' ||
		normalized === 'DELETE'
	)
}

export function isSearchPath(path: string): boolean {
	return /(?:^|\/)search(?:\/|$|\?)/.test(path)
}

export function inferOperationFromPath(
	method: string,
	path: string,
	explicit?: ZendeskOperation,
): ZendeskOperation {
	if (explicit) return explicit
	const mutating = isMutatingMethod(method) && !isSearchPath(path)
	if (/(?:^|\/)users\/me(?:\/|$|\?)/.test(path)) {
		return mutating ? 'unknownMutation' : 'me.read'
	}
	if (/(?:^|\/)tickets(?:\/|$|\?)/.test(path)) {
		return mutating ? 'tickets.write' : 'tickets.read'
	}
	if (/(?:^|\/)users(?:\/|$|\?)/.test(path)) {
		return mutating ? 'users.write' : 'users.read'
	}
	if (/(?:^|\/)help_center\/(?:.+\/*)?articles(?:\/|$|\?)/.test(path) || /\/articles(?:\/|$|\?)/.test(path)) {
		return mutating ? 'articles.write' : 'articles.read'
	}
	if (/(?:^|\/)search(?:\/|$|\?)/.test(path)) {
		return mutating ? 'unknownMutation' : 'tickets.read'
	}
	return mutating ? 'unknownMutation' : 'unknownRead'
}

export function normalizeZendeskPath(path: string, subdomain?: string): string {
	const trimmed = requireString(path, 'path')
	if (trimmed.startsWith('https://')) {
		const url = new URL(trimmed)
		if (subdomain) {
			const expected = zendeskHost(subdomain)
			if (url.host !== expected) {
				throw new Error(`Zendesk requests must use ${expected}. Got ${url.host}.`)
			}
		} else if (!url.host.endsWith('.zendesk.com')) {
			throw new Error(`Zendesk requests must use {subdomain}.zendesk.com. Got ${url.host}.`)
		}
		return `${url.pathname}${url.search}`
	}
	return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
}

export function zendeskUrl(
	path: string,
	query: JsonRecord | undefined,
	subdomain: string,
): string {
	const normalized = normalizeZendeskPath(path, subdomain)
	const withoutApiPrefix = normalized.replace(/^\/api\/v2(?=\/|$)/, '')
	const relative = withoutApiPrefix.replace(/^\//, '')
	const url = new URL(relative, `${resolveApiBaseUrl(subdomain)}/`)
	if (query) {
		for (const [key, value] of Object.entries(query)) {
			if (value === undefined || value === null) continue
			if (typeof value === 'boolean' || typeof value === 'number') {
				url.searchParams.set(key, String(value))
				continue
			}
			if (typeof value === 'string') {
				url.searchParams.set(key, value)
				continue
			}
			throw new Error(`query.${key} must be a string, number, or boolean.`)
		}
	}
	return url.toString()
}

export function mutationPreview(
	input: { confirm?: boolean; dryRun?: boolean },
	preview: { method: string; path: string; url: string; body?: JsonRecord },
): DryRunResult | null {
	if (!input.dryRun) {
		if (input.confirm !== true) {
			throw new Error(
				`${preview.method} ${preview.path} requires confirm: true after explicit user approval, or dryRun: true.`,
			)
		}
		return null
	}
	return {
		dryRun: true,
		method: preview.method,
		path: preview.path,
		url: preview.url,
		body: preview.body,
	}
}

export const OAUTH_CONNECT_URL = oauthConnectUrl()
export const API_TOKEN_SETUP_URL = apiTokenSetupUrl()
export const API_USERNAME_SETUP_URL = apiUsernameSetupUrl()