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