Skip to content

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

Package listing

@kody/airtable

src/setup.ts

210 lines · 6.7 KB · TypeScript
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}`
}