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()