← back to Ventura Claw

server/connectors/cloudflare.js

75 lines

// VenturaClaw — Cloudflare connector (REAL API)
// Reads CF_API_TOKEN from env (account-scoped or zone-scoped per Steve's MEMORY note about token leak).
// Docs: https://developers.cloudflare.com/api/

const CF_API = "https://api.cloudflare.com/client/v4";

function token(creds) {
  const t = creds?.CF_API_TOKEN || process.env.CF_API_TOKEN;
  if (!t) throw new Error("CF_API_TOKEN not set (user creds or env)");
  return t;
}

async function call(method, path, body, creds) {
  const r = await fetch(`${CF_API}${path}`, {
    method,
    headers: { "Authorization": `Bearer ${token(creds)}`, "Content-Type": "application/json" },
    body: body ? JSON.stringify(body) : undefined
  });
  const d = await r.json();
  if (!d.success) throw new Error(`cloudflare ${path} failed: ${JSON.stringify(d.errors).slice(0,300)}`);
  return d.result;
}

async function zoneByName(name, creds) {
  // Try exact match, then walk parent domains (handles subdomains)
  let parts = name.split(".");
  while (parts.length >= 2) {
    const candidate = parts.join(".");
    const zones = await call("GET", `/zones?name=${encodeURIComponent(candidate)}`, null, creds);
    if (zones.length) return zones[0];
    parts = parts.slice(1);
  }
  throw new Error(`zone not found for ${name}`);
}

const cloudflare = {
  meta: { id: "cloudflare", name: "Cloudflare", category: "security", docsUrl: "https://developers.cloudflare.com/api/", auth: "api_key", realImpl: true },
  fields: [
    { key: "CF_API_TOKEN", label: "API Token", type: "password", required: true, hint: "Cloudflare > My Profile > API Tokens. Use account-scoped token, NOT global key." }
  ],
  configured(creds) { return !!(creds?.CF_API_TOKEN || process.env.CF_API_TOKEN); },
  async health(creds) {
    if (!this.configured(creds)) return { ok: false, reason: "no_token" };
    try {
      const verify = await call("GET", "/user/tokens/verify", null, creds);
      return { ok: true, status: verify.status, expires_on: verify.expires_on };
    } catch (e) { return { ok: false, reason: e.message }; }
  },
  actions: {
    async "zone.list"(_, creds) { return call("GET", "/zones?per_page=50", null, creds); },
    async "dns.list"({ zone }, creds) {
      const z = await zoneByName(zone, creds);
      return call("GET", `/zones/${z.id}/dns_records?per_page=200`, null, creds);
    },
    async "dns.create"({ zone, type, name, content, ttl, proxied }, creds) {
      const z = await zoneByName(zone, creds);
      return call("POST", `/zones/${z.id}/dns_records`, { type, name, content, ttl: ttl || 1, proxied: !!proxied }, creds);
    },
    async "dns.update"({ zone, record_id, type, name, content, ttl, proxied }, creds) {
      const z = await zoneByName(zone, creds);
      return call("PATCH", `/zones/${z.id}/dns_records/${record_id}`, { type, name, content, ttl, proxied }, creds);
    },
    async "cache.purge_all"({ zone }, creds) {
      const z = await zoneByName(zone, creds);
      return call("POST", `/zones/${z.id}/purge_cache`, { purge_everything: true }, creds);
    },
    async "cache.purge_files"({ zone, files }, creds) {
      const z = await zoneByName(zone, creds);
      return call("POST", `/zones/${z.id}/purge_cache`, { files }, creds);
    }
  }
};

module.exports = cloudflare;