import { hubspotFetch, resolveHubSpotAuth, type ResolvedHubSpotAuth } from './auth.ts' import type { HubSpotAuthInput, HubSpotPageInfo, JsonRecord } from './types.ts' import { requireString } from './types.ts' import { HUBSPOT_API_BASE_URL, HUBSPOT_REQUIRED_HOST, inferOperationFromPath, isMutatingMethod, nextStepForScope, scopeForOperation, type HubSpotOperation, } from './setup.ts' const ALLOWED_HOSTS = new Set(['api.hubapi.com', 'api.hubspot.com']) export class HubSpotApiError extends Error { readonly status: number readonly operation: HubSpotOperation readonly details: unknown readonly missingScope: string | null constructor( message: string, options: { status: number operation: HubSpotOperation details: unknown missingScope: string | null }, ) { super(message) this.name = 'HubSpotApiError' this.status = options.status this.operation = options.operation this.details = options.details this.missingScope = options.missingScope } } export type HubSpotRequestInput = HubSpotAuthInput & { path: string method?: string query?: JsonRecord body?: JsonRecord operation?: HubSpotOperation } export type HubSpotResponse = { data: T pageInfo: HubSpotPageInfo status: number } function hubspotErrorMessage(body: unknown): string | null { if (!body || typeof body !== 'object') return null const record = body as { message?: unknown; category?: unknown } if (typeof record.message === 'string' && record.message.length > 0) { return record.message } return null } function looksLikeInsufficientScope(status: number, body: unknown): boolean { if (status === 401 || status === 403) return true const message = (hubspotErrorMessage(body) ?? '').toLowerCase() const category = body && typeof body === 'object' && typeof (body as { category?: unknown }).category === 'string' ? String((body as { category: string }).category).toLowerCase() : '' return ( message.includes('missing scope') || message.includes('insufficient') || message.includes('not authorized') || message.includes('forbidden') || category.includes('permission') || category.includes('authorization') ) } export function normalizeHubSpotPath(path: string): string { const trimmed = requireString(path, 'path') if (trimmed.startsWith('https://')) { const url = new URL(trimmed) if (!ALLOWED_HOSTS.has(url.host)) { throw new Error( `HubSpot requests must use host ${HUBSPOT_REQUIRED_HOST} (or api.hubspot.com). Got ${url.host}.`, ) } return `${url.pathname}${url.search}` } return trimmed.startsWith('/') ? trimmed : `/${trimmed}` } export function hubspotUrl(path: string, query?: JsonRecord): string { const normalized = normalizeHubSpotPath(path) const url = new URL(normalized, `${HUBSPOT_API_BASE_URL}/`) if (query) { for (const [key, value] of Object.entries(query)) { if (value === undefined || value === null) continue if (typeof value === 'boolean' || typeof value === 'number') { url.searchParams.set(key, String(value)) continue } if (typeof value === 'string') { url.searchParams.set(key, value) continue } if (Array.isArray(value) && value.every((item) => typeof item === 'string')) { url.searchParams.set(key, value.join(',')) continue } throw new Error(`query.${key} must be a string, number, boolean, or string array.`) } } return url.toString() } export async function hubspotRequest( input: HubSpotRequestInput, ): Promise> { const method = (input.method ?? 'GET').toUpperCase() const path = requireString(input.path, 'path') const operation = inferOperationFromPath(method, normalizeHubSpotPath(path), input.operation) const auth = await resolveHubSpotAuth(input) return hubspotRequestWithAuth(auth, { method, path, query: input.query, body: input.body, operation, }) } export async function hubspotRequestWithAuth( auth: ResolvedHubSpotAuth, input: { method: string path: string query?: JsonRecord body?: JsonRecord operation: HubSpotOperation }, ): Promise> { const url = hubspotUrl(input.path, input.query) const headers = new Headers({ Accept: 'application/json', 'User-Agent': 'kody-hubspot/1.0', }) const init: RequestInit = { method: input.method, headers } if (input.body !== undefined) { headers.set('Content-Type', 'application/json') init.body = JSON.stringify(input.body) } const response = await hubspotFetch(auth, url, init) const text = await response.text() let parsed: unknown = text try { parsed = text ? JSON.parse(text) : null } catch { parsed = text } if (!response.ok || looksLikeInsufficientScope(response.status, parsed)) { const missingScope = scopeForOperation(input.operation) const firstMessage = hubspotErrorMessage(parsed) || (typeof parsed === 'string' ? parsed.slice(0, 400) : response.statusText) throw new HubSpotApiError( [ `HubSpot ${input.operation} failed (${response.status}): ${firstMessage}.`, `This call needs the "${missingScope}" permission.`, nextStepForScope(missingScope, { authMode: auth.mode, integrationName: auth.integrationName, secretName: auth.secretName, }), ].join(' '), { status: response.status, operation: input.operation, details: parsed, missingScope, }, ) } const record = parsed && typeof parsed === 'object' ? (parsed as JsonRecord) : {} const paging = record.paging && typeof record.paging === 'object' ? (record.paging as { next?: { after?: unknown } }) : null const after = typeof paging?.next?.after === 'string' ? paging.next.after : typeof record.after === 'string' ? record.after : null const data = ('results' in record ? record.results : parsed) as T return { data, pageInfo: { hasNextPage: Boolean(after), after, }, status: response.status, } } export { isMutatingMethod, inferOperationFromPath }