import { figmaFetch, resolveFigmaAuth, type ResolvedFigmaAuth } from './auth.ts' import type { FigmaAuthInput, JsonRecord } from './types.ts' import { requireString } from './types.ts' import { FIGMA_API_BASE_URL, FIGMA_REQUIRED_HOST, inferOperationFromPath, isMutatingMethod, nextStepForScope, scopeForOperation, type FigmaOperation, } from './setup.ts' export class FigmaApiError extends Error { readonly status: number readonly operation: FigmaOperation readonly details: unknown readonly missingScope: string | null constructor( message: string, options: { status: number operation: FigmaOperation details: unknown missingScope: string | null }, ) { super(message) this.name = 'FigmaApiError' this.status = options.status this.operation = options.operation this.details = options.details this.missingScope = options.missingScope } } export type FigmaRequestInput = FigmaAuthInput & { path: string method?: string query?: JsonRecord body?: JsonRecord operation?: FigmaOperation } export type FigmaResponse = { status: number body: T } function figmaErrorMessage(body: unknown): string | null { if (!body || typeof body !== 'object') { return typeof body === 'string' && body.trim() ? body.slice(0, 400) : null } const record = body as { err?: unknown; error?: unknown; message?: unknown } if (typeof record.err === 'string' && record.err.trim()) return record.err if (typeof record.message === 'string' && record.message.trim()) return record.message if (typeof record.error === 'string' && record.error.trim()) return record.error return null } function looksLikeInsufficientScope(status: number, message: string | null): boolean { if (status === 401 || status === 403) return true const lower = (message ?? '').toLowerCase() return ( lower.includes('insufficient') || lower.includes('missing scope') || lower.includes('not authorized') || lower.includes('forbidden') || lower.includes('invalid token') || lower.includes('do not have permission') ) } export function normalizeFigmaPath(path: string): string { const trimmed = requireString(path, 'path') if (trimmed.startsWith('https://')) { const url = new URL(trimmed) if (url.host !== FIGMA_REQUIRED_HOST) { throw new Error(`Figma requests must use host ${FIGMA_REQUIRED_HOST}. Got ${url.host}.`) } return `${url.pathname}${url.search}` } if (trimmed.startsWith('/v1/') || trimmed.startsWith('/v2/')) return trimmed if (trimmed === '/v1' || trimmed === '/v2') return trimmed return trimmed.startsWith('/') ? `/v1${trimmed}` : `/v1/${trimmed}` } export function figmaUrl(path: string, query?: JsonRecord): string { const normalized = normalizeFigmaPath(path) const url = new URL(normalized, `${FIGMA_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 } throw new Error(`query.${key} must be a string, number, or boolean.`) } } return url.toString() } export async function figmaRequest( input: FigmaRequestInput, ): Promise> { const method = (input.method ?? 'GET').toUpperCase() const path = requireString(input.path, 'path') const operation = inferOperationFromPath(method, normalizeFigmaPath(path), input.operation) const auth = await resolveFigmaAuth(input) return figmaRequestWithAuth(auth, { method, path, query: input.query, body: input.body, operation, }) } export async function figmaRequestWithAuth( auth: ResolvedFigmaAuth, input: { method: string path: string query?: JsonRecord body?: JsonRecord operation: FigmaOperation }, ): Promise> { const url = figmaUrl(input.path, input.query) const headers = new Headers({ Accept: 'application/json', 'User-Agent': 'kody-figma/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 figmaFetch(auth, url, init) const text = await response.text() let parsed: unknown = text try { parsed = text ? JSON.parse(text) : null } catch { parsed = text } const message = figmaErrorMessage(parsed) const bodyError = parsed && typeof parsed === 'object' && (parsed as { error?: unknown }).error === true if (!response.ok || bodyError || looksLikeInsufficientScope(response.status, message)) { const missingScope = scopeForOperation(input.operation) throw new FigmaApiError( [ `Figma ${input.operation} failed (${response.status}): ${message || response.statusText}.`, `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, }, ) } return { status: response.status, body: parsed as T, } } export { inferOperationFromPath, isMutatingMethod }