Skip to content

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

Package listing

@kody/trello

src/setup.ts

240 lines · 8.4 KB · TypeScript
import type { DryRunResult, JsonRecord, TrelloAuthInput, TrelloAuthMode } from './types.ts'
import { optionalString, requireString } from './types.ts'

const ALLOWED_HOSTS = new Set(['api.trello.com'])

export const TRELLO_API_BASE_URL = 'https://api.trello.com/1'
export const TRELLO_REQUIRED_HOSTS = ['api.trello.com'] as const
export const TRELLO_REQUIRED_HOST = 'api.trello.com'
export const TRELLO_CALLBACK_URL = 'https://kody.codes/connect/oauth'
export const TRELLO_APPS_ADMIN_URL = 'https://trello.com/power-ups/admin'
export const TRELLO_AUTH_DOCS_URL =
	'https://developer.atlassian.com/cloud/trello/guides/rest-api/authorization/'
export const TRELLO_AUTHORIZE_URL = 'https://trello.com/1/authorize'
export const TRELLO_OAUTH_REQUEST_TOKEN_URL = 'https://trello.com/1/OAuthGetRequestToken'
export const TRELLO_OAUTH_AUTHORIZE_TOKEN_URL = 'https://trello.com/1/OAuthAuthorizeToken'
export const TRELLO_OAUTH_ACCESS_TOKEN_URL = 'https://trello.com/1/OAuthGetAccessToken'
export const DEFAULT_INTEGRATION_NAME = 'trello'
export const DEFAULT_API_KEY_SECRET = 'trelloApiKey'
export const DEFAULT_TOKEN_SECRET = 'trelloToken'
export const DEFAULT_AUTHORIZE_SCOPES = ['read', 'write'] as const

export const TRELLO_SCOPES = {
	read: 'Read boards, lists, cards, and comments on behalf of the user.',
	write: 'Create or update boards, lists, cards, and comments.',
	account: 'Read the member email and write member info. Not required by this package.',
} as const

export function accountSuffix(input: TrelloAuthInput = {}): string | null {
	const integrationName = resolveIntegrationName(input)
	if (integrationName === DEFAULT_INTEGRATION_NAME) return null
	return integrationName.startsWith('trello-')
		? integrationName.slice('trello-'.length)
		: integrationName
}

export function resolveIntegrationName(input: TrelloAuthInput = {}): 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 === 'trello') {
		return DEFAULT_INTEGRATION_NAME
	}
	if (account.startsWith('trello-')) return account
	return `trello-${account}`
}

export function resolveTokenSecretName(input: TrelloAuthInput = {}): string {
	const explicit = optionalString(input.secretName, 'secretName')
	if (explicit) return explicit
	const suffix = accountSuffix(input)
	return suffix ? `${DEFAULT_TOKEN_SECRET}-${suffix}` : DEFAULT_TOKEN_SECRET
}

export function resolveApiKeySecretName(input: TrelloAuthInput = {}): string {
	const explicit = optionalString(input.apiKeySecretName, 'apiKeySecretName')
	if (explicit) return explicit
	const suffix = accountSuffix(input)
	return suffix ? `${DEFAULT_API_KEY_SECRET}-${suffix}` : DEFAULT_API_KEY_SECRET
}

export function resolveSecretName(input: TrelloAuthInput = {}): string {
	return resolveTokenSecretName(input)
}

export function oauthConnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string {
	const name = requireString(provider, 'provider')
	const params = new URLSearchParams({
		provider: name,
		apiBaseUrl: TRELLO_API_BASE_URL,
		allowedHosts: TRELLO_REQUIRED_HOSTS.join(','),
		dashboardUrl: TRELLO_APPS_ADMIN_URL,
		flow: 'confidential',
	})
	return `https://kody.codes/connect/oauth?${params.toString()}`
}

export function secretSetupUrl(
	secretName: string,
	description: string,
): string {
	const name = requireString(secretName, 'secretName')
	const params = new URLSearchParams({
		name,
		description,
		allowedHosts: TRELLO_REQUIRED_HOSTS.join(','),
		scope: 'user',
	})
	return `https://kody.codes/account/secrets/new?${params.toString()}`
}

export function apiKeySetupUrl(secretName: string = DEFAULT_API_KEY_SECRET): string {
	return secretSetupUrl(secretName, 'Trello API key for boards, lists, cards, and comments')
}

export function tokenSetupUrl(secretName: string = DEFAULT_TOKEN_SECRET): string {
	return secretSetupUrl(secretName, 'Trello user token for boards, lists, cards, and comments')
}

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

export function trelloAuthorizeUrl(options: {
	apiKey?: string
	name?: string
	scope?: string
	expiration?: string
} = {}): string {
	const params = new URLSearchParams({
		expiration: options.expiration ?? 'never',
		name: options.name ?? 'Kody',
		scope: options.scope ?? DEFAULT_AUTHORIZE_SCOPES.join(','),
		response_type: 'token',
		key: options.apiKey ?? 'YOUR_API_KEY',
	})
	return `${TRELLO_AUTHORIZE_URL}?${params.toString()}`
}

export function missingCredentialsMessage(options: {
	integrationName: string
	apiKeySecretName: string
	tokenSecretName: string
}): string {
	return [
		'Trello credentials are missing.',
		'API key + token (recommended; Trello REST is key+token today): create a Power-Up at',
		TRELLO_APPS_ADMIN_URL,
		'then save the API key and user token (do not paste values in chat):',
		apiKeySetupUrl(options.apiKeySecretName),
		tokenSetupUrl(options.tokenSecretName),
		'Authorize a user token with your API key at',
		trelloAuthorizeUrl(),
		`OAuth reconnect (if a saved ${options.integrationName} integration already exists):`,
		reconnectUrl(options.integrationName),
		`BYO connect page: ${oauthConnectUrl(options.integrationName)}`,
		`Redirect URI for a future Trello OAuth 2 app: ${TRELLO_CALLBACK_URL}.`,
		`Required API host: ${TRELLO_REQUIRED_HOST}. Auth docs: ${TRELLO_AUTH_DOCS_URL}.`,
	].join(' ')
}

export function nextStepForAuth(options: {
	authMode: TrelloAuthMode
	integrationName: string
	apiKeySecretName: string
	tokenSecretName: string
}): string {
	switch (options.authMode) {
		case 'oauth':
			return [
				`Reconnect the "${options.integrationName}" OAuth integration.`,
				reconnectUrl(options.integrationName),
				`Or use Trello API key + token: ${apiKeySetupUrl(options.apiKeySecretName)}`,
				tokenSetupUrl(options.tokenSecretName),
			].join(' ')
		case 'keyToken':
			return [
				`Save Trello API key ${options.apiKeySecretName} and token ${options.tokenSecretName}, then approve host ${TRELLO_REQUIRED_HOST}.`,
				TRELLO_APPS_ADMIN_URL,
				apiKeySetupUrl(options.apiKeySecretName),
				tokenSetupUrl(options.tokenSecretName),
				trelloAuthorizeUrl(),
			].join(' ')
		default: {
			const exhaustive: never = options.authMode
			throw new Error(`Unsupported Trello auth mode: ${String(exhaustive)}`)
		}
	}
}

export function normalizeTrelloPath(path: string): string {
	const trimmed = requireString(path, 'path')
	if (trimmed.startsWith('https://')) {
		const url = new URL(trimmed)
		if (!ALLOWED_HOSTS.has(url.host)) {
			throw new Error(`Trello requests must use host ${TRELLO_REQUIRED_HOST}. Got ${url.host}.`)
		}
		return `${url.pathname}${url.search}`
	}
	const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
	if (withSlash === '/1' || withSlash.startsWith('/1/')) return withSlash
	return `/1${withSlash}`
}

export function trelloUrl(path: string, query?: JsonRecord): string {
	const normalized = normalizeTrelloPath(path)
	const url = new URL(normalized, 'https://api.trello.com')
	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 isMutatingMethod(method: string): boolean {
	const normalized = method.toUpperCase()
	return (
		normalized === 'POST' ||
		normalized === 'PUT' ||
		normalized === 'PATCH' ||
		normalized === 'DELETE'
	)
}

export function mutationPreview(
	input: { confirm?: boolean; dryRun?: boolean },
	preview: { method: string; path: 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,
		body: preview.body,
	}
}

export const OAUTH_CONNECT_URL = oauthConnectUrl()
export const API_KEY_SETUP_URL = apiKeySetupUrl()
export const TOKEN_SETUP_URL = tokenSetupUrl()
export const TRELLO_AUTHORIZE_PAGE_URL = trelloAuthorizeUrl()