import { createAuthenticatedFetch, kody } from 'kody:runtime' import type { AirtableAuthInput, AirtableAuthMode, JsonRecord } from './types.ts' import { optionalString, requireString } from './types.ts' import { DEFAULT_INTEGRATION_NAME, DEFAULT_PAT_SECRET, missingCredentialsMessage, } from './setup.ts' const authenticatedFetches = new Map() export type ResolvedAirtableAuth = { mode: AirtableAuthMode integrationName: string secretName: string authorization: string } function secretEntries( result: unknown, ): Array<{ name?: string; scope?: string }> { if ( result && typeof result === 'object' && Array.isArray((result as { secrets?: unknown }).secrets) ) { return (result as { secrets: Array<{ name?: string; scope?: string }> }).secrets } return Array.isArray(result) ? result : [] } export async function listUserSecretNames(): Promise> { const listed = await kody.secretList({ scope: 'user' }) const names = new Set() for (const entry of secretEntries(listed)) { if (entry?.name && (entry.scope === 'user' || !entry.scope)) { names.add(entry.name) } } return names } export async function listAirtableIntegrationNames(): Promise> { const listed = await kody.integrationList({}) const integrations = listed && typeof listed === 'object' && Array.isArray((listed as { integrations?: unknown }).integrations) ? (listed as { integrations: Array<{ name?: string }> }).integrations : Array.isArray(listed) ? listed : [] return integrations .map((item) => (typeof item?.name === 'string' ? item.name : '')) .filter((name) => name === 'airtable' || name.startsWith('airtable-')) } export function resolveIntegrationName(input: AirtableAuthInput = {}): string { const explicit = optionalString(input.integrationName, 'integrationName') ?? optionalString(input.integration, 'integration') if (explicit) return explicit const account = optionalString(input.account, 'account') if (!account || account === 'default' || account === 'airtable') { return DEFAULT_INTEGRATION_NAME } if (account.startsWith('airtable-')) return account return `airtable-${account}` } export function resolveSecretName(input: AirtableAuthInput = {}): string { const explicit = optionalString(input.secretName, 'secretName') if (explicit) return explicit const integrationName = resolveIntegrationName(input) if (integrationName === DEFAULT_INTEGRATION_NAME) return DEFAULT_PAT_SECRET const suffix = integrationName.startsWith('airtable-') ? integrationName.slice('airtable-'.length) : integrationName return `airtablePat-${suffix}` } export async function resolveAirtableAuth( input: AirtableAuthInput = {}, ): Promise { const integrationName = resolveIntegrationName(input) const secretName = resolveSecretName(input) const forced = input.auth if (forced !== undefined && forced !== 'oauth' && forced !== 'pat') { throw new Error('auth must be "oauth" or "pat".') } const [secrets, integrations] = await Promise.all([ listUserSecretNames(), listAirtableIntegrationNames(), ]) const hasOauth = integrations.includes(integrationName) const hasPat = secrets.has(secretName) let mode: AirtableAuthMode if (forced === 'oauth') { if (!hasOauth) { throw new Error(missingCredentialsMessage({ integrationName, secretName })) } mode = 'oauth' } else if (forced === 'pat') { if (!hasPat) { throw new Error(missingCredentialsMessage({ integrationName, secretName })) } mode = 'pat' } else if (hasOauth) { mode = 'oauth' } else if (hasPat) { mode = 'pat' } else { throw new Error(missingCredentialsMessage({ integrationName, secretName })) } switch (mode) { case 'oauth': return { mode, integrationName, secretName, authorization: `[managed ${integrationName} OAuth integration]`, } case 'pat': return { mode, integrationName, secretName, authorization: `Bearer {{secret:${secretName}}}`, } default: { const exhaustive: never = mode throw new Error(`Unsupported Airtable auth mode: ${String(exhaustive)}`) } } } async function getOauthFetch(integrationName: string): Promise { let authedFetch = authenticatedFetches.get(integrationName) if (!authedFetch) { try { authedFetch = await createAuthenticatedFetch(integrationName) } catch (error) { const message = error instanceof Error ? error.message : String(error) throw new Error( [ `Airtable OAuth integration "${integrationName}" is not usable: ${message}`, missingCredentialsMessage({ integrationName, secretName: DEFAULT_PAT_SECRET, }), ].join(' '), ) } authenticatedFetches.set(integrationName, authedFetch) } return authedFetch } export async function airtableFetch( auth: ResolvedAirtableAuth, url: string, init: RequestInit, ): Promise { switch (auth.mode) { case 'oauth': { const authedFetch = await getOauthFetch(auth.integrationName) return authedFetch(url, init) } case 'pat': { const headers = new Headers(init.headers) headers.set('Authorization', `Bearer {{secret:${auth.secretName}}}`) return fetch(url, { ...init, headers }) } default: { const exhaustive: never = auth.mode throw new Error(`Unsupported Airtable auth mode: ${String(exhaustive)}`) } } } export function requireAuthMode(value: unknown): AirtableAuthMode | undefined { if (value === undefined) return undefined const mode = requireString(value, 'auth') if (mode !== 'oauth' && mode !== 'pat') { throw new Error('auth must be "oauth" or "pat".') } return mode } export function pickAuthInput(input: JsonRecord): AirtableAuthInput { return { integrationName: optionalString(input.integrationName, 'integrationName'), integration: optionalString(input.integration, 'integration'), account: optionalString(input.account, 'account'), secretName: optionalString(input.secretName, 'secretName'), auth: requireAuthMode(input.auth), } }