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