import type { AirtableAuthMode } from './types.ts' import { requireString } from './types.ts' export const AIRTABLE_API_BASE_URL = 'https://api.airtable.com/v0' export const AIRTABLE_AUTHORIZE_URL = 'https://airtable.com/oauth2/v1/authorize' export const AIRTABLE_TOKEN_URL = 'https://airtable.com/oauth2/v1/token' export const AIRTABLE_REQUIRED_HOST = 'api.airtable.com' export const AIRTABLE_OAUTH_APP_URL = 'https://airtable.com/create/oauth' export const AIRTABLE_PAT_URL = 'https://airtable.com/create/tokens' export const AIRTABLE_CALLBACK_URL = 'https://kody.codes/connect/oauth' export const DEFAULT_INTEGRATION_NAME = 'airtable' export const DEFAULT_PAT_SECRET = 'airtablePat' export const DEFAULT_OAUTH_SCOPES = [ 'data.records:read', 'data.records:write', 'data.recordComments:read', 'data.recordComments:write', 'schema.bases:read', ] as const export const AIRTABLE_SCOPES = { 'data.records:read': 'List and read records.', 'data.records:write': 'Create, update, or delete records.', 'data.recordComments:read': 'List comments on records.', 'data.recordComments:write': 'Create comments on records.', 'schema.bases:read': 'List bases and read table schema.', 'schema.bases:write': 'Create or update bases, tables, and fields.', } as const export type AirtableScope = keyof typeof AIRTABLE_SCOPES export type AirtableOperation = | 'whoami' | 'bases.read' | 'tables.read' | 'records.read' | 'records.write' | 'comments.read' | 'comments.write' | 'unknownRead' | 'unknownMutation' export function oauthConnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string { const name = requireString(provider, 'provider') const params = new URLSearchParams({ provider: name, authorizeUrl: AIRTABLE_AUTHORIZE_URL, tokenUrl: AIRTABLE_TOKEN_URL, apiBaseUrl: AIRTABLE_API_BASE_URL, scopes: DEFAULT_OAUTH_SCOPES.join(' '), flow: 'confidential', pkce: 'true', tokenExchangeStyle: 'basic-form', allowedHosts: AIRTABLE_REQUIRED_HOST, dashboardUrl: AIRTABLE_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: 'Airtable personal access token', allowedHosts: AIRTABLE_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: AirtableOperation): AirtableScope | null { switch (operation) { case 'whoami': return null case 'bases.read': case 'tables.read': return 'schema.bases:read' case 'records.read': return 'data.records:read' case 'records.write': return 'data.records:write' case 'comments.read': return 'data.recordComments:read' case 'comments.write': return 'data.recordComments:write' case 'unknownRead': return 'schema.bases:read' case 'unknownMutation': return 'data.records:write' default: { const exhaustive: never = operation throw new Error(`Unsupported Airtable operation: ${String(exhaustive)}`) } } } export function nextStepForScope( scope: AirtableScope | null, options: { authMode: AirtableAuthMode integrationName: string secretName: string }, ): string { const scopeHint = scope ? `This call needs the "${scope}" permission.` : 'Confirm the token is valid and the selected bases are granted to it.' switch (options.authMode) { case 'oauth': return [ scopeHint, `Reconnect the "${options.integrationName}" OAuth integration.`, reconnectUrl(options.integrationName), `Full BYO connect URL (scopes ${DEFAULT_OAUTH_SCOPES.join(' ')}): ${oauthConnectUrl(options.integrationName)}`, ].join(' ') case 'pat': return [ scopeHint, `Create an Airtable personal access token, grant the needed scopes and bases, then save it as ${options.secretName}.`, AIRTABLE_PAT_URL, patSetupUrl(options.secretName), ].join(' ') default: { const exhaustive: never = options.authMode throw new Error(`Unsupported Airtable auth mode: ${String(exhaustive)}`) } } } export function missingCredentialsMessage(options: { integrationName: string secretName: string }): string { return [ 'Airtable credentials are missing.', 'OAuth (recommended for shared/multi-account use): register an OAuth integration at', AIRTABLE_OAUTH_APP_URL, `with redirect URI ${AIRTABLE_CALLBACK_URL}, generate a client secret, then connect:`, oauthConnectUrl(options.integrationName), `Personal access token (fastest for one workspace): create a PAT at ${AIRTABLE_PAT_URL}`, 'then save it (do not paste the value in chat):', patSetupUrl(options.secretName), `Required API host: ${AIRTABLE_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?: AirtableOperation, ): AirtableOperation { if (explicit) return explicit const mutating = isMutatingMethod(method) if (/\/meta\/whoami(?:\/|$|\?)/.test(path)) return mutating ? 'unknownMutation' : 'whoami' if (/\/comments(?:\/|$|\?)/.test(path)) { return mutating ? 'comments.write' : 'comments.read' } if (/\/meta\/bases\/[^/]+\/tables(?:\/|$|\?)/.test(path)) { return mutating ? 'unknownMutation' : 'tables.read' } if (/\/meta\/bases(?:\/|$|\?)/.test(path)) { return mutating ? 'unknownMutation' : 'bases.read' } if (/\/v0\/app[^/]+\/[^/]+/.test(path) || /\/v0\/[^/]+\/[^/]+/.test(path)) { return mutating ? 'records.write' : 'records.read' } return mutating ? 'unknownMutation' : 'unknownRead' } export function recordsPath( baseId: string, tableIdOrName: string, recordId?: string, suffix?: string, ): string { const base = `/v0/${encodeURIComponent(baseId)}/${encodeURIComponent(tableIdOrName)}` const withRecord = recordId ? `${base}/${encodeURIComponent(recordId)}` : base return suffix ? `${withRecord}/${suffix}` : withRecord } export function normalizeAirtablePath(path: string): string { const trimmed = requireString(path, 'path') if (trimmed.startsWith('https://')) { const url = new URL(trimmed) if (url.host !== AIRTABLE_REQUIRED_HOST) { throw new Error( `Airtable requests must use host ${AIRTABLE_REQUIRED_HOST}. Got ${url.host}.`, ) } return `${url.pathname}${url.search}` } if (trimmed.startsWith('/v0/')) return trimmed if (trimmed.startsWith('/v0')) return trimmed return trimmed.startsWith('/') ? `/v0${trimmed}` : `/v0/${trimmed}` }