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'
}