Skip to content
← Public packages

@kody/api-research

Research third-party APIs: registry search, provider discovery, OpenAPI summarize, and client scaffold.

src/discover.ts

298 lines · 8.2 KB · TypeScript
import {
	BoundedBodyTooLargeError,
	readBoundedBody,
} from './bounded-body.ts'
import { getErrorMessage } from './error-message.ts'
import { isRecord } from './is-record.ts'

const INTEGRATIONS_SH_API_BASE = 'https://integrations.sh/api'
const MAX_DISCOVER_BODY_BYTES = 500_000
const SURFACE_FETCH_TIMEOUT_MS = 15_000
const LIVE_DISCOVER_FETCH_TIMEOUT_MS = 75_000
const CACHED_DISCOVERY_STALE_AFTER_MS = 30 * 24 * 60 * 60 * 1000

const HOSTNAME_PATTERN =
	/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/

export type DiscoverCredential = {
	type: string
	label?: string
	setup?: string
	generateUrl?: string
	acquisition?: string
}

export type DiscoverSurface = {
	type: string
	url?: string
	spec?: string
	name?: string
	docs?: string
	evidence?: Array<string>
}

export type DiscoverInput = {
	/** Provider domain to discover (for example linear.app or stripe.com). */
	domain: string
}

export type DiscoverOutput = {
	domain: string
	summary: string | null
	description: string | null
	discoveredAt: string | null
	credentials: Record<string, DiscoverCredential>
	surfaces: Array<DiscoverSurface>
	source: string
	provenance: 'cached' | 'live'
	liveDiscoveryError: string | null
}

type RegistryDocument = {
	domain: string
	summary?: string | null
	description?: string | null
	discoveredAt?: string | null
	credentials?: Record<string, Record<string, unknown>> | null
	surfaces?: Array<Record<string, unknown>> | null
}

export function normalizeProviderDomain(domain: string): string {
	const normalized = domain.trim().toLowerCase()
	if (normalized.length === 0) {
		throw new Error('Provider domain is required')
	}
	if (/\s/.test(normalized)) {
		throw new Error('Provider domain must not contain whitespace')
	}
	if (normalized.includes('/')) {
		throw new Error('Provider domain must be a bare hostname without a path')
	}
	if (/^[a-z][a-z0-9+.-]*:/i.test(normalized)) {
		throw new Error('Provider domain must not include a URL scheme')
	}
	if (!HOSTNAME_PATTERN.test(normalized)) {
		throw new Error('Provider domain must look like a hostname')
	}
	return normalized
}

export function buildSurfaceUrl(domain: string): string {
	return `${INTEGRATIONS_SH_API_BASE}/${encodeURIComponent(domain)}/surface`
}

export function buildDiscoverUrl(domain: string): string {
	return `${INTEGRATIONS_SH_API_BASE}/${encodeURIComponent(domain)}/discover`
}

function mapCredentials(
	credentials: RegistryDocument['credentials'],
): DiscoverOutput['credentials'] {
	if (!credentials) {
		return {}
	}
	const mapped: DiscoverOutput['credentials'] = {}
	for (const [id, entry] of Object.entries(credentials)) {
		mapped[id] = {
			type: String(entry.type ?? ''),
			...(typeof entry.label === 'string' ? { label: entry.label } : {}),
			...(typeof entry.setup === 'string' ? { setup: entry.setup } : {}),
			...(typeof entry.generateUrl === 'string'
				? { generateUrl: entry.generateUrl }
				: {}),
			...(typeof entry.acquisition === 'string'
				? { acquisition: entry.acquisition }
				: {}),
		}
	}
	return mapped
}

function mapSurfaces(
	surfaces: RegistryDocument['surfaces'],
): DiscoverOutput['surfaces'] {
	if (!surfaces) {
		return []
	}
	return surfaces.map((surface) => {
		const basis = isRecord(surface.basis) ? surface.basis : null
		return {
			type: String(surface.type ?? ''),
			...(typeof surface.url === 'string' ? { url: surface.url } : {}),
			...(typeof surface.spec === 'string' ? { spec: surface.spec } : {}),
			...(typeof surface.name === 'string' ? { name: surface.name } : {}),
			...(typeof surface.docs === 'string' ? { docs: surface.docs } : {}),
			...(Array.isArray(basis?.evidence)
				? {
						evidence: basis.evidence.filter(
							(item): item is string => typeof item === 'string',
						),
					}
				: {}),
		}
	})
}

type RegistryFetchResult =
	| { outcome: 'success'; document: RegistryDocument }
	| { outcome: 'not-found' }
	| { outcome: 'failure'; message: string }

async function fetchRegistryDocument(
	url: string,
	timeoutMs: number,
): Promise<RegistryFetchResult> {
	let response: Response
	try {
		response = await fetch(url, {
			headers: { Accept: 'application/json' },
			redirect: 'follow',
			signal: AbortSignal.timeout(timeoutMs),
		})
	} catch (cause) {
		return { outcome: 'failure', message: getErrorMessage(cause) }
	}

	if (response.status === 404) {
		return { outcome: 'not-found' }
	}
	if (!response.ok) {
		return { outcome: 'failure', message: `HTTP ${response.status} for ${url}` }
	}

	let body: string
	try {
		body = await readBoundedBody(response, MAX_DISCOVER_BODY_BYTES)
	} catch (cause) {
		if (cause instanceof BoundedBodyTooLargeError) {
			return { outcome: 'failure', message: cause.message }
		}
		return { outcome: 'failure', message: getErrorMessage(cause) }
	}

	let parsed: unknown
	try {
		parsed = JSON.parse(body)
	} catch (cause) {
		return {
			outcome: 'failure',
			message: `invalid JSON (${getErrorMessage(cause)})`,
		}
	}

	if (!isRecord(parsed) || typeof parsed.domain !== 'string') {
		return { outcome: 'failure', message: 'unexpected response shape' }
	}

	return {
		outcome: 'success',
		document: parsed as RegistryDocument,
	}
}

function hasUsableRegistryData(document: RegistryDocument): boolean {
	const surfaceCount = document.surfaces?.length ?? 0
	const credentialCount = Object.keys(document.credentials ?? {}).length
	return surfaceCount > 0 || credentialCount > 0
}

function isCachedDiscoveryFresh(document: RegistryDocument): boolean {
	if (document.discoveredAt == null) {
		return false
	}
	const discoveredAt = Date.parse(document.discoveredAt)
	if (Number.isNaN(discoveredAt)) {
		return false
	}
	return Date.now() - discoveredAt <= CACHED_DISCOVERY_STALE_AFTER_MS
}

function toDiscoverOutput(
	document: RegistryDocument,
	meta: {
		source: string
		provenance: DiscoverOutput['provenance']
		liveDiscoveryError: string | null
	},
): DiscoverOutput {
	return {
		domain: document.domain,
		summary: document.summary ?? null,
		description: document.description ?? null,
		discoveredAt: document.discoveredAt ?? null,
		credentials: mapCredentials(document.credentials),
		surfaces: mapSurfaces(document.surfaces),
		source: meta.source,
		provenance: meta.provenance,
		liveDiscoveryError: meta.liveDiscoveryError,
	}
}

/**
 * Load integrations.sh discovery for a provider domain.
 * Use after search-registry when you need credential types and spec URLs.
 *
 * @param input - Bare provider hostname
 * @returns Untrusted surfaces, credentials, and provenance
 *
 * @example
 * import discover from 'kody:@kody/api-research/discover'
 * const found = await discover({ domain: 'github.com' })
 */
export default async function discover(
	input: DiscoverInput,
): Promise<DiscoverOutput> {
	const normalized = normalizeProviderDomain(input.domain)
	const surfaceUrl = buildSurfaceUrl(normalized)
	const discoverUrl = buildDiscoverUrl(normalized)

	const cached = await fetchRegistryDocument(
		surfaceUrl,
		SURFACE_FETCH_TIMEOUT_MS,
	)
	const cachedDocument =
		cached.outcome === 'success' && hasUsableRegistryData(cached.document)
			? cached.document
			: null

	if (cachedDocument != null && isCachedDiscoveryFresh(cachedDocument)) {
		return toDiscoverOutput(cachedDocument, {
			source: surfaceUrl,
			provenance: 'cached',
			liveDiscoveryError: null,
		})
	}

	const live = await fetchRegistryDocument(
		discoverUrl,
		LIVE_DISCOVER_FETCH_TIMEOUT_MS,
	)
	if (live.outcome === 'success') {
		return toDiscoverOutput(live.document, {
			source: discoverUrl,
			provenance: 'live',
			liveDiscoveryError: null,
		})
	}

	const liveFailureMessage =
		live.outcome === 'not-found' ? `HTTP 404 for ${discoverUrl}` : live.message

	if (cachedDocument != null) {
		return toDiscoverOutput(cachedDocument, {
			source: surfaceUrl,
			provenance: 'cached',
			liveDiscoveryError: `live discovery failed (${liveFailureMessage}); returning cached registry data discovered at ${cachedDocument.discoveredAt ?? 'an unknown time'}`,
		})
	}

	if (live.outcome === 'not-found' && cached.outcome === 'not-found') {
		throw new Error(
			`Domain "${normalized}" is not in the integrations.sh registry. Try search-registry to find the canonical provider domain.`,
		)
	}

	throw new Error(
		`integrations.sh discover failed: ${liveFailureMessage}. No usable cached surface data is available for "${normalized}".`,
	)
}