Skip to content
← Public packages

@kody/cloudflare

Call Cloudflare zones, Workers, analytics, WAF, and Log Explorer with a saved API token.

src/api-v4.ts

112 lines · 3.4 KB · TypeScript
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'

export type CloudflareApiV4Input = {
  method?: HttpMethod | string
  path?: string
  query?: Record<string, string | number | boolean | null | undefined>
  body?: unknown
}

export type CloudflareApiV4Result = {
  status: number
  httpStatus: number
  body: unknown
  success: boolean
  result: unknown
  resultInfo: unknown
  errors: unknown[]
  messages: unknown[]
}

const ALLOWED_METHODS = new Set<HttpMethod>(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
const BASE_URL = 'https://api.cloudflare.com'

function assertSafePath(path) {
  const trimmed = String(path || '').trim()
  if (!trimmed.startsWith('/')) {
    throw new Error('path must start with `/` and must not include a host.')
  }
  if (!trimmed.startsWith('/client/v4/')) {
    throw new Error('path must start with `/client/v4/`.')
  }
  if (trimmed.includes('..')) {
    throw new Error('path must not contain `..`.')
  }
  if (/\s|#/.test(trimmed)) {
    throw new Error('path contains disallowed characters.')
  }
  if (trimmed.length > 2048) {
    throw new Error('path exceeds maximum length.')
  }
  return trimmed
}

function normalizeMethod(method) {
  const normalized = String(method || 'GET').trim().toUpperCase()
  if (!ALLOWED_METHODS.has(normalized)) {
    throw new Error('method must be one of GET, POST, PUT, PATCH, or DELETE.')
  }
  return normalized
}

/**
 * Call a Cloudflare API v4 endpoint under `/client/v4/` using `cloudflareApiToken`.
 * @param params.path - Relative path starting with `/client/v4/`.
 * @returns Parsed JSON body with `success`, `result`, `errors`, and HTTP status.
 * @example
 * import apiV4 from 'kody:@kody/cloudflare/api-v4'
 * const result = await apiV4({ path: '/client/v4/user/tokens/verify' })
 * // => { success: true, result: { ... }, errors: [], ... }
 */
export default async function cloudflareApiV4(params: CloudflareApiV4Input = {}): Promise<CloudflareApiV4Result> {
  const method = normalizeMethod(params.method)
  const path = assertSafePath(params.path)
  const url = new URL(path, BASE_URL)
  const query = params.query && typeof params.query === 'object' ? params.query : null
  if (query) {
    for (const [key, value] of Object.entries(query)) {
      if (value === undefined || value === null) continue
      url.searchParams.set(String(key), String(value))
    }
  }

  const headers = {
    Accept: 'application/json',
    Authorization: 'Bearer {{secret:cloudflareApiToken}}',
    'User-Agent': 'kody-cloudflare/1.0',
  }
  const init = { method, headers }
  if (params.body !== undefined && method !== 'GET') {
    headers['Content-Type'] = 'application/json'
    init.body = JSON.stringify(params.body)
  }
  const response = await fetch(url.toString(), init)
  const text = await response.text()
  if (response.status === 204 || !text.trim()) {
    return {
      status: response.status,
      httpStatus: response.status,
      body: null,
      success: response.ok,
      result: null,
      resultInfo: null,
      errors: [],
      messages: [],
    }
  }
  try {
    const body = JSON.parse(text)
    return {
      status: response.status,
      httpStatus: response.status,
      body,
      success: body.success === true,
      result: body.result,
      resultInfo: body.result_info,
      errors: body.errors || [],
      messages: body.messages || [],
    }
  } catch {
    throw new Error('Cloudflare API returned non-JSON (' + response.status + ').')
  }
}