← 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 · TypeScriptimport {
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}".`,
)
}