import type { CfDnsRecord } from "@cfdm/shared"; import { AppError } from "../../errors.js"; import { parseRetryAfter } from "../cf-retry.js"; export const CF_API_BASE = "https://api.cloudflare.com/client/v4"; export interface CfResponse { success: boolean; result?: T; errors?: Array<{ code: number; message: string }>; } export function mapCloudflareFailure( operation: string, status: number, message: string, ): AppError { const lower = message.toLowerCase(); if (status === 401 || status === 403 || lower.includes("authentication")) { if ( operation.includes("workers") || operation.includes("kv_") || operation.includes("accounts") ) { return AppError.cloudflareAuthFailed( "Токену нужны права Account: Workers Scripts Write и Workers KV Storage Write. Zone DNS недостаточно.", ); } return AppError.cloudflareAuthFailed( "Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.", ); } if (status === 429 || lower.includes("rate limit")) { return AppError.rateLimited(); } if (lower.includes("zone") && (lower.includes("not found") || status === 404)) { return AppError.zoneNotFound(); } if ( operation.includes("healthcheck") && (lower.includes("plan") || lower.includes("not entitled") || lower.includes("not allowed") || lower.includes("permission")) ) { return AppError.healthcheckCreateFailed( "Cloudflare Health Checks недоступны для этой зоны. Используйте локальные проверки.", ); } if (operation.includes("dns") || operation.includes("dns_record")) { return AppError.dnsUpdateFailed(`Не удалось обновить DNS в Cloudflare: ${message}`); } return AppError.cloudflare(`${operation}: ${message}`); } export async function handleCfResponse( response: Response, operation: string, ): Promise { if (response.status === 429) { const wait = parseRetryAfter(response.headers) ?? 5000; throw AppError.rateLimited( `Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`, ); } const body = (await response.json()) as CfResponse; if (!body.success) { const msg = body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error"; throw mapCloudflareFailure(operation, response.status, msg); } if (body.result === undefined) { throw mapCloudflareFailure(operation, response.status, "empty result"); } return body.result; } /** KV PUT / schedules often return `{ success: true }` without `result`. */ export async function handleCfSuccess( response: Response, operation: string, ): Promise { if (response.status === 429) { const wait = parseRetryAfter(response.headers) ?? 5000; throw AppError.rateLimited( `Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`, ); } const text = await response.text(); if (!text) { if (!response.ok) { throw mapCloudflareFailure(operation, response.status, String(response.status)); } return; } let body: CfResponse; try { body = JSON.parse(text) as CfResponse; } catch { if (!response.ok) { throw mapCloudflareFailure(operation, response.status, text.slice(0, 180)); } return; } if (!body.success) { const msg = body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error"; throw mapCloudflareFailure(operation, response.status, msg); } } export async function cfRequest( token: string, path: string, operation: string, init: RequestInit = {}, ): Promise { const response = await fetch(`${CF_API_BASE}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, ...(init.body ? { "Content-Type": "application/json" } : {}), ...init.headers, }, signal: init.signal ?? AbortSignal.timeout(30_000), }); if (response.status >= 500 || response.status === 429) { throw mapCloudflareFailure(operation, response.status, String(response.status)); } return handleCfResponse(response, operation); } export type DnsRecordResult = CfDnsRecord;