Skip to content

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

Package listing

@kody/asana

src/setup.ts

189 lines · 5.7 KB · TypeScript
import type { AsanaAuthMode } from './types.ts'
import { requireString } from './types.ts'

export const ASANA_API_BASE_URL = 'https://app.asana.com/api/1.0'
export const ASANA_AUTHORIZE_URL = 'https://app.asana.com/-/oauth_authorize'
export const ASANA_TOKEN_URL = 'https://app.asana.com/-/oauth_token'
export const ASANA_REQUIRED_HOST = 'app.asana.com'
export const ASANA_OAUTH_APP_URL = 'https://app.asana.com/0/my-apps'
export const ASANA_PAT_URL = 'https://app.asana.com/0/my-apps'
export const ASANA_CALLBACK_URL = 'https://kody.codes/connect/oauth'
export const DEFAULT_INTEGRATION_NAME = 'asana'
export const DEFAULT_PAT_SECRET = 'asanaPat'

export const DEFAULT_OAUTH_SCOPES = [
	'workspaces:read',
	'projects:read',
	'projects:write',
	'tasks:read',
	'tasks:write',
	'stories:read',
	'stories:write',
	'users:read',
] as const

export const ASANA_SCOPES = {
	default: 'Full permissions for every documented REST endpoint. Use when an endpoint has no granular scope yet.',
	'workspaces:read': 'List and read workspaces.',
	'projects:read': 'List and read projects.',
	'projects:write': 'Create or update projects.',
	'tasks:read': 'List and read tasks.',
	'tasks:write': 'Create or update tasks.',
	'stories:read': 'List and read stories and comments.',
	'stories:write': 'Create comments (stories) on tasks.',
	'users:read': 'Read the authorizing user (used by viewer / smoke-test).',
} as const

export type AsanaScope = keyof typeof ASANA_SCOPES

export type AsanaOperation =
	| 'workspaces.read'
	| 'projects.read'
	| 'projects.write'
	| 'tasks.read'
	| 'tasks.write'
	| 'stories.read'
	| 'stories.write'
	| 'users.read'
	| 'unknownRead'
	| 'unknownMutation'

export function oauthConnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string {
	const name = requireString(provider, 'provider')
	const params = new URLSearchParams({
		provider: name,
		authorizeUrl: ASANA_AUTHORIZE_URL,
		tokenUrl: ASANA_TOKEN_URL,
		apiBaseUrl: ASANA_API_BASE_URL,
		scopes: DEFAULT_OAUTH_SCOPES.join(' '),
		flow: 'confidential',
		pkce: 'true',
		allowedHosts: ASANA_REQUIRED_HOST,
		dashboardUrl: ASANA_OAUTH_APP_URL,
	})
	return `https://kody.codes/connect/oauth?${params.toString()}`
}

export function patSetupUrl(secretName: string = DEFAULT_PAT_SECRET): string {
	const name = requireString(secretName, 'secretName')
	const params = new URLSearchParams({
		name,
		description: 'Asana personal access token',
		allowedHosts: ASANA_REQUIRED_HOST,
		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 scopeForOperation(operation: AsanaOperation): AsanaScope {
	switch (operation) {
		case 'workspaces.read':
			return 'workspaces:read'
		case 'projects.read':
			return 'projects:read'
		case 'projects.write':
			return 'projects:write'
		case 'tasks.read':
			return 'tasks:read'
		case 'tasks.write':
			return 'tasks:write'
		case 'stories.read':
			return 'stories:read'
		case 'stories.write':
			return 'stories:write'
		case 'users.read':
			return 'users:read'
		case 'unknownRead':
			return 'default'
		case 'unknownMutation':
			return 'default'
		default: {
			const exhaustive: never = operation
			throw new Error(`Unsupported Asana operation: ${String(exhaustive)}`)
		}
	}
}

export function nextStepForScope(
	scope: AsanaScope,
	options: {
		authMode: AsanaAuthMode
		integrationName: string
		secretName: string
	},
): string {
	switch (options.authMode) {
		case 'oauth':
			return [
				`Reconnect the "${options.integrationName}" OAuth integration and include the "${scope}" scope.`,
				reconnectUrl(options.integrationName),
				`Full BYO connect URL (scopes ${DEFAULT_OAUTH_SCOPES.join(' ')}): ${oauthConnectUrl(options.integrationName)}`,
			].join(' ')
		case 'pat':
			return [
				`Create an Asana personal access token, then save it as ${options.secretName}.`,
				ASANA_PAT_URL,
				patSetupUrl(options.secretName),
			].join(' ')
		default: {
			const exhaustive: never = options.authMode
			throw new Error(`Unsupported Asana auth mode: ${String(exhaustive)}`)
		}
	}
}

export function missingCredentialsMessage(options: {
	integrationName: string
	secretName: string
}): string {
	return [
		'Asana credentials are missing.',
		'OAuth (recommended for shared/multi-account use): create an OAuth app at',
		ASANA_OAUTH_APP_URL,
		`with redirect URI ${ASANA_CALLBACK_URL}, then connect:`,
		oauthConnectUrl(options.integrationName),
		`Personal access token (fastest for one workspace): create a PAT at ${ASANA_PAT_URL}`,
		'then save it (do not paste the value in chat):',
		patSetupUrl(options.secretName),
		`Required API host: ${ASANA_REQUIRED_HOST}.`,
	].join(' ')
}

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

export function inferOperationFromPath(
	method: string,
	path: string,
	explicit?: AsanaOperation,
): AsanaOperation {
	if (explicit) return explicit
	const mutating = isMutatingMethod(method)
	if (/\/stories(?:\/|$)/.test(path) || /\/tasks\/[^/]+\/stories/.test(path)) {
		return mutating ? 'stories.write' : 'stories.read'
	}
	if (/\/tasks(?:\/|$)/.test(path)) {
		return mutating ? 'tasks.write' : 'tasks.read'
	}
	if (/\/projects(?:\/|$)/.test(path)) {
		return mutating ? 'projects.write' : 'projects.read'
	}
	if (/\/workspaces(?:\/|$)/.test(path)) {
		return mutating ? 'unknownMutation' : 'workspaces.read'
	}
	if (/\/users(?:\/|$)/.test(path)) {
		return mutating ? 'unknownMutation' : 'users.read'
	}
	return mutating ? 'unknownMutation' : 'unknownRead'
}