UNPKG

@hasna/domains

Version:

Domain portfolio, registrar, marketplace, and DNS management for AI agents — CLI + MCP + HTTP API + SDK, shared account API and PostgreSQL

1,114 lines (1,108 loc) • 48 kB
// src/lib/cloudflare.ts import { createHash } from "node:crypto"; // src/lib/dns-txt.ts function decodeDnsTxt(value) { const input = value.trim(); if (!input.startsWith('"')) return value; let result = ""; let i = 0; while (i < input.length) { if (input[i++] !== '"') throw new Error("Malformed DNS TXT presentation"); let closed = false; while (i < input.length) { let char = input[i++]; if (char === '"') { closed = true; break; } if (char === "\\") { if (i === input.length) throw new Error("Malformed DNS TXT escape"); if (/\d/u.test(input[i])) { const digits = input.slice(i, i + 3); if (!/^\d{3}$/u.test(digits) || Number(digits) > 255) throw new Error("Malformed DNS TXT octet"); char = String.fromCharCode(Number(digits)); i += 3; } else char = input[i++]; } if (/[\x00-\x1f\x7f]/u.test(char)) throw new Error("DNS TXT control character is not supported"); result += char; } if (!closed) throw new Error("Unterminated DNS TXT string"); if (i < input.length && !/\s/u.test(input[i])) throw new Error("Malformed DNS TXT string separator"); while (i < input.length && /\s/u.test(input[i])) i++; } return result; } function mailPolicyKind(value) { const semantic = decodeDnsTxt(value).trim(); if (/^v=spf1(?:\s|$)/iu.test(semantic)) return "spf"; if (/^v=DMARC1(?:;|\s|$)/iu.test(semantic)) return "dmarc"; return null; } // src/lib/env-aliases.ts function firstEnv(env, names) { for (const key of names) { const value = env[key]; if (value) return { key, value }; } return; } function hasEveryEnv(env, groups) { return groups.every((names) => !!firstEnv(env, names)); } function flattenEnvNames(groups) { return Array.from(new Set(groups.flatMap((names) => [...names]))); } var NAMECHEAP_ENV = { apiKey: ["NAMECHEAP_API_KEY"], username: ["NAMECHEAP_USERNAME"], clientIp: ["NAMECHEAP_CLIENT_IP"] }; var GODADDY_ENV = { apiKey: ["GODADDY_API_KEY"], apiSecret: ["GODADDY_API_SECRET"] }; var BRANDSIGHT_ENV = { apiKey: ["BRANDSIGHT_API_KEY"], apiSecret: ["BRANDSIGHT_API_SECRET"], customerId: ["BRANDSIGHT_CUSTOMER_ID"], shopperId: ["BRANDSIGHT_SHOPPER_ID"], accountId: ["BRANDSIGHT_ACCOUNT_ID"] }; var SEDO_ENV = { partnerId: ["SEDO_PARTNER_ID"], signKey: ["SEDO_API_KEY", "SEDO_SIGN_KEY"], username: ["SEDO_USERNAME", "SEDO_EMAIL"], password: ["SEDO_PASSWORD"] }; var CLOUDFLARE_ENV = { apiToken: ["CLOUDFLARE_API_TOKEN"], apiKey: ["CLOUDFLARE_API_KEY"], email: ["CLOUDFLARE_EMAIL"], accountId: ["CLOUDFLARE_ACCOUNT_ID"] }; var ROUTE53_ENV = { accessKeyId: ["ROUTE53_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID"], secretAccessKey: ["ROUTE53_SECRET_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY"], sessionToken: ["ROUTE53_SESSION_TOKEN", "AWS_SESSION_TOKEN"], profile: ["ROUTE53_AWS_PROFILE", "AWS_PROFILE"], region: ["ROUTE53_REGION", "ROUTE53_AWS_REGION", "AWS_REGION"], webIdentityTokenFile: ["AWS_WEB_IDENTITY_TOKEN_FILE"], roleArn: ["AWS_ROLE_ARN"], containerCredentials: [ "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_CONTAINER_CREDENTIALS_FULL_URI" ] }; function providerEnvNames(provider) { switch (provider.toLowerCase()) { case "namecheap": return flattenEnvNames([NAMECHEAP_ENV.apiKey, NAMECHEAP_ENV.username, NAMECHEAP_ENV.clientIp]); case "godaddy": return flattenEnvNames([GODADDY_ENV.apiKey, GODADDY_ENV.apiSecret]); case "brandsight": return flattenEnvNames([ BRANDSIGHT_ENV.apiKey, BRANDSIGHT_ENV.apiSecret, BRANDSIGHT_ENV.customerId, BRANDSIGHT_ENV.shopperId, BRANDSIGHT_ENV.accountId ]); case "sedo": return flattenEnvNames([SEDO_ENV.partnerId, SEDO_ENV.signKey, SEDO_ENV.username, SEDO_ENV.password]); case "cloudflare": return flattenEnvNames([CLOUDFLARE_ENV.apiToken, CLOUDFLARE_ENV.apiKey, CLOUDFLARE_ENV.email, CLOUDFLARE_ENV.accountId]); case "route53": return flattenEnvNames([ ROUTE53_ENV.profile, ROUTE53_ENV.accessKeyId, ROUTE53_ENV.secretAccessKey, ROUTE53_ENV.sessionToken, ROUTE53_ENV.region, ROUTE53_ENV.webIdentityTokenFile, ROUTE53_ENV.roleArn, ROUTE53_ENV.containerCredentials ]); default: return []; } } function hasProviderCredentials(provider, env = process.env) { switch (provider.toLowerCase()) { case "namecheap": return hasEveryEnv(env, [NAMECHEAP_ENV.apiKey, NAMECHEAP_ENV.username, NAMECHEAP_ENV.clientIp]); case "godaddy": return hasEveryEnv(env, [GODADDY_ENV.apiKey, GODADDY_ENV.apiSecret]); case "brandsight": return hasEveryEnv(env, [BRANDSIGHT_ENV.apiKey, BRANDSIGHT_ENV.apiSecret, BRANDSIGHT_ENV.customerId]); case "sedo": return hasEveryEnv(env, [SEDO_ENV.partnerId, SEDO_ENV.signKey, SEDO_ENV.username, SEDO_ENV.password]); case "cloudflare": { const tokenMode = !!firstEnv(env, CLOUDFLARE_ENV.apiToken); const keyMode = hasEveryEnv(env, [CLOUDFLARE_ENV.apiKey, CLOUDFLARE_ENV.email]); return tokenMode || keyMode; } case "route53": { const profileMode = !!firstEnv(env, ROUTE53_ENV.profile); const keyMode = hasEveryEnv(env, [ROUTE53_ENV.accessKeyId, ROUTE53_ENV.secretAccessKey]); const webIdentityMode = hasEveryEnv(env, [ROUTE53_ENV.webIdentityTokenFile, ROUTE53_ENV.roleArn]); const containerMode = !!firstEnv(env, ROUTE53_ENV.containerCredentials); return profileMode || keyMode || webIdentityMode || containerMode; } default: return false; } } // src/lib/cloudflare-auth.ts function resolveCloudflareConfig(env = process.env) { const accountId = firstEnv(env, CLOUDFLARE_ENV.accountId)?.value; const apiToken = firstEnv(env, CLOUDFLARE_ENV.apiToken)?.value; if (apiToken) return { apiToken, accountId }; const apiKey = firstEnv(env, CLOUDFLARE_ENV.apiKey)?.value; const email = firstEnv(env, CLOUDFLARE_ENV.email)?.value; if (apiKey && email) return { apiKey, email, accountId }; return accountId ? { accountId } : {}; } function cloudflareAuthHeaders(cfg) { if (cfg.apiToken) return { Authorization: `Bearer ${cfg.apiToken}` }; if (cfg.apiKey && cfg.email) return { "X-Auth-Key": cfg.apiKey, "X-Auth-Email": cfg.email }; throw new Error("Cloudflare credentials not configured. Set CLOUDFLARE_API_TOKEN, or CLOUDFLARE_API_KEY + CLOUDFLARE_EMAIL."); } // src/lib/cloudflare.ts class CloudflareApiError extends Error { statusCode; providerCode; constructor(message, statusCode, providerCode) { const code = providerCode ? `, code ${providerCode}` : ""; super(`Cloudflare API error (HTTP ${statusCode}${code}): ${message}`); this.statusCode = statusCode; this.providerCode = providerCode; this.name = "CloudflareApiError"; } } function getConfig() { return resolveCloudflareConfig(); } function checkCredentials(cfg) { cloudflareAuthHeaders(cfg); } var CF_BASE = "https://api.cloudflare.com/client/v4"; var DEFAULT_CF_TIMEOUT_MS = 30000; var MAX_CF_TIMEOUT_MS = 120000; var DEFAULT_CF_MAX_RESPONSE_BYTES = 1048576; var MAX_CF_RESPONSE_BYTES = 4194304; function positiveBoundedEnv(name, fallback, maximum) { const raw = process.env[name]?.trim(); if (!raw) return fallback; const value = Number(raw); if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { throw new Error(`${name} must be an integer between 1 and ${maximum}`); } return value; } async function boundedResponseText(response, maxBytes, signal) { const contentLength = Number(response.headers.get("content-length")); if (Number.isFinite(contentLength) && contentLength > maxBytes) { throw new Error(`Cloudflare API response exceeds ${maxBytes} bytes`); } if (!response.body) return ""; const reader = response.body.getReader(); const abort = () => void reader.cancel("Cloudflare API request aborted"); if (signal.aborted) abort(); signal.addEventListener("abort", abort, { once: true }); const chunks = []; let length = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; length += value.byteLength; if (length > maxBytes) { await reader.cancel("Cloudflare API response exceeds bounded size"); throw new Error(`Cloudflare API response exceeds ${maxBytes} bytes`); } chunks.push(value); } } finally { signal.removeEventListener("abort", abort); } const bytes = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return new TextDecoder().decode(bytes); } async function cfFetch(path, opts = {}) { const cfg = opts.config ?? getConfig(); checkCredentials(cfg); const timeoutMs = Math.min(opts.timeoutMs ?? MAX_CF_TIMEOUT_MS, positiveBoundedEnv("DOMAINS_PROVIDER_HTTP_TIMEOUT_MS", DEFAULT_CF_TIMEOUT_MS, MAX_CF_TIMEOUT_MS)); const maxResponseBytes = positiveBoundedEnv("DOMAINS_PROVIDER_MAX_RESPONSE_BYTES", DEFAULT_CF_MAX_RESPONSE_BYTES, MAX_CF_RESPONSE_BYTES); const controller = new AbortController; let timer; const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error(`Cloudflare API request exceeded ${timeoutMs}ms`)); }, timeoutMs); }); let json; let status = 0; try { ({ json, status } = await Promise.race([ (async () => { const response = await fetch(`${CF_BASE}${path}`, { method: opts.method ?? "GET", headers: { ...cloudflareAuthHeaders(cfg), "Content-Type": "application/json" }, body: opts.body ? JSON.stringify(opts.body) : undefined, signal: controller.signal }); const text = await boundedResponseText(response, maxResponseBytes, controller.signal); try { return { status: response.status, json: JSON.parse(text) }; } catch { throw new Error(`Cloudflare API returned invalid JSON (${response.status})`); } })(), timeout ])); } finally { if (timer !== undefined) clearTimeout(timer); } if (status < 200 || status >= 300 || json.success !== true) { const first = json.errors?.[0]; const msg = first?.message ?? "request failed"; const providerCode = typeof first?.code === "string" || typeof first?.code === "number" ? String(first.code) : undefined; throw new CloudflareApiError(msg, status, providerCode); } opts.onResultInfo?.(json.result_info); return json.result; } function exactDnsName(value, label) { if (typeof value !== "string") throw new Error(`${label} must be a fully qualified DNS name`); const name = value.toLowerCase().replace(/\.$/, ""); if (name.length > 253 || !name.includes(".") || !name.split(".").every((part) => part.length > 0 && part.length <= 63 && /^[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?$/.test(part))) { throw new Error(`${label} must be a fully qualified DNS name without wildcards`); } return name; } async function createRecordIfAbsent(input, config) { if (!/^[a-f0-9]{32}$/.test(input.zoneId)) throw new Error("zoneId must be an exact Cloudflare zone ID"); const zoneName = exactDnsName(input.zoneName, "zoneName"); const name = exactDnsName(input.name, "name"); const content = exactDnsName(input.content, "content"); if (!name.endsWith(`.${zoneName}`)) throw new Error("name must be a fully qualified subdomain of the exact zone"); if (name === content) throw new Error("CNAME cannot point to itself"); if (input.type !== "CNAME" || input.proxied !== false) throw new Error("create-only currently requires type CNAME and explicit proxied false"); if (!Number.isSafeInteger(input.ttl) || !(input.ttl === 1 || input.ttl >= 60 && input.ttl <= 86400)) { throw new Error("ttl must be 1 (automatic) or an integer between 60 and 86400"); } const base = `/zones/${input.zoneId}`; const zone = await cfFetch(base, { config }); if (!zone || zone.id !== input.zoneId || exactDnsName(zone.name, "provider zone name") !== zoneName || zone.status !== "active") { throw new Error("Provider zone identity or active status does not match the requested zone"); } const readName = async () => { const records = await cfFetch(`${base}/dns_records?name=${encodeURIComponent(name)}&per_page=100&page=1`, { config }); if (!Array.isArray(records) || records.some((record) => !record || exactDnsName(record.name, "provider record name") !== name)) { throw new Error("Provider returned invalid exact-name DNS records"); } return records; }; if ((await readName()).length !== 0) throw new Error("DNS name already exists; create-only refuses every existing record"); const body = { type: "CNAME", name, content, ttl: input.ttl, proxied: false }; const matches = (record, id) => !!record && typeof record.id === "string" && /^[a-f0-9]{32}$/.test(record.id) && (!id || record.id === id) && record.type === "CNAME" && exactDnsName(record.name, "provider record name") === name && exactDnsName(record.content, "provider record content") === content && record.ttl === input.ttl && record.proxied === false; try { const created = await cfFetch(`${base}/dns_records`, { method: "POST", body, config }); if (!matches(created)) throw new Error("Provider create result does not match the exact requested record"); const record = await cfFetch(`${base}/dns_records/${created.id}`, { config }); const atName = await readName(); if (!matches(record, created.id) || atName.length !== 1 || !matches(atName[0], created.id)) { throw new Error("Provider readback does not match the exact created record ID and content"); } return { created: true, provider: "cloudflare", zone: { id: zone.id, name: zoneName }, record }; } catch (error) { throw new Error(`DNS create is unverified; no retry or rollback was attempted. Read the exact provider name before retrying. ${error instanceof Error ? error.message : String(error)}`, { cause: error }); } } async function updateRecordInPlace(input, config) { if (!/^[a-f0-9]{32}$/.test(input.zoneId)) throw new Error("zoneId must be an exact Cloudflare zone ID"); if (!/^[a-f0-9]{32}$/.test(input.recordId)) throw new Error("recordId must be an exact Cloudflare record ID"); const zoneName = exactDnsName(input.zoneName, "zoneName"); const { update } = input; const body = {}; if (update.content !== undefined) { if (typeof update.content !== "string" || !update.content.trim() || update.content.length > 2048 || /[\u0000-\u001f\u007f]/u.test(update.content)) { throw new Error("content must be a non-empty DNS value without control characters"); } body.content = update.content.trim(); } if (update.ttl !== undefined) { if (!Number.isSafeInteger(update.ttl) || !(update.ttl === 1 || update.ttl >= 60 && update.ttl <= 86400)) { throw new Error("ttl must be 1 (automatic) or an integer between 60 and 86400"); } body.ttl = update.ttl; } if (update.proxied !== undefined) { if (typeof update.proxied !== "boolean") throw new Error("proxied must be a boolean"); body.proxied = update.proxied; } if (Object.keys(body).length === 0) throw new Error("At least one of content, ttl or proxied must be given"); if (update.expectContent !== undefined && (typeof update.expectContent !== "string" || !update.expectContent.trim())) { throw new Error("expectContent must be a non-empty DNS value"); } const base = `/zones/${input.zoneId}`; const zone = await cfFetch(base, { config }); if (!zone || zone.id !== input.zoneId || exactDnsName(zone.name, "provider zone name") !== zoneName || zone.status !== "active") { throw new Error("Provider zone identity or active status does not match the requested zone"); } const readRecord = async () => { const record = await cfFetch(`${base}/dns_records/${input.recordId}`, { config }); if (!record || record.id !== input.recordId) { throw new Error("Provider record identity does not match the exact requested record ID"); } return record; }; const previous = await readRecord(); if (update.expectContent !== undefined && previous.content !== update.expectContent.trim()) { throw new Error(`DNS record content is ${previous.content}, not the expected ${update.expectContent}; refusing the change`); } try { await cfFetch(`${base}/dns_records/${input.recordId}`, { method: "PATCH", body, config }); const record = await readRecord(); if (record.type !== previous.type || record.name !== previous.name) { throw new Error("Provider readback changed the record identity (type or name)"); } if (body.content !== undefined && record.content !== body.content || body.ttl !== undefined && record.ttl !== body.ttl || body.proxied !== undefined && record.proxied !== body.proxied) { throw new Error("Provider readback does not match the requested record fields"); } return { changed: true, provider: "cloudflare", zone: { id: zone.id, name: zoneName }, previous, record }; } catch (error) { throw new Error(`DNS change is unverified; no retry or rollback was attempted. Read the exact provider record before retrying. ${error instanceof Error ? error.message : String(error)}`, { cause: error }); } } async function listZones(config) { const zones = []; let page = 1; while (true) { const result = await cfFetch(`/zones?per_page=50&page=${page}`, { config }); if (!result || result.length === 0) break; for (const z of result) { zones.push({ id: z.id, name: z.name, status: z.status, nameservers: z.name_servers, original_nameservers: z.original_name_servers }); } if (result.length < 50) break; page++; } return zones; } async function getZone(domain, config) { const result = await cfFetch(`/zones?name=${encodeURIComponent(domain)}`, { config }); if (!result || result.length === 0) return null; const z = result[0]; return { id: z.id, name: z.name, status: z.status, nameservers: z.name_servers, original_nameservers: z.original_name_servers }; } async function createZone(domain, config) { const cfg = config ?? getConfig(); if (!cfg.accountId) { throw new Error("CLOUDFLARE_ACCOUNT_ID is required to create a zone."); } const result = await cfFetch("/zones", { method: "POST", body: { name: domain, account: { id: cfg.accountId }, jump_start: false }, config: cfg }); return { id: result.id, name: result.name, status: result.status, nameservers: result.name_servers }; } async function ensureZone(domain, config, deps) { const get = deps?.getZone ?? getZone; const create = deps?.createZone ?? createZone; const existing = await get(domain, config); const zone = existing ?? await create(domain, config); if (!zone.nameservers || zone.nameservers.length === 0) { throw new Error(`Cloudflare zone for ${domain} has no nameservers yet`); } return zone; } async function deleteZone(zoneId, config) { await cfFetch(`/zones/${zoneId}`, { method: "DELETE", config }); } async function getZoneOriginTlsMode(zoneId, config) { const setting = await cfFetch(`/zones/${encodeURIComponent(zoneId)}/settings/ssl`, { config }); if (setting.id !== "ssl" || typeof setting.value !== "string" || !setting.value.trim()) { throw new Error("Cloudflare returned an invalid zone SSL setting"); } const mode = setting.value.trim().toLowerCase(); if (!["off", "flexible", "full", "strict", "origin_pull"].includes(mode)) { throw new Error(`Cloudflare returned unsupported zone SSL setting ${mode}`); } return mode; } async function ensureZoneOriginTlsMode(zoneId, requested, config) { const current = await getZoneOriginTlsMode(zoneId, config); if (current === "origin_pull" || current === "strict" && requested === "full") { return { mode: current, changed: false, downgradeRefused: true }; } let changed = false; if (current !== requested) { await cfFetch(`/zones/${encodeURIComponent(zoneId)}/settings/ssl`, { method: "PATCH", body: { value: requested }, config }); changed = true; } const readback = await getZoneOriginTlsMode(zoneId, config); if (readback !== requested) { throw new Error(`Cloudflare zone SSL setting readback is ${readback}, expected ${requested}`); } return { mode: requested, changed, downgradeRefused: false }; } var CLOUDFLARE_ZONE_SETTINGS = { email_obfuscation: ["on", "off"] }; function requireZoneSettingName(setting) { if (typeof setting !== "string" || !Object.hasOwn(CLOUDFLARE_ZONE_SETTINGS, setting)) { throw new Error(`Unsupported Cloudflare zone setting '${setting}'; supported: ${Object.keys(CLOUDFLARE_ZONE_SETTINGS).join(", ")}`); } return setting; } function requireZoneSettingValue(setting, value) { const allowed = CLOUDFLARE_ZONE_SETTINGS[setting]; if (typeof value !== "string" || !allowed.includes(value.trim().toLowerCase())) { throw new Error(`Unsupported value '${value}' for zone setting ${setting}; supported: ${allowed.join(", ")}`); } return value.trim().toLowerCase(); } function requireZoneId(zoneId) { const id = typeof zoneId === "string" ? zoneId.trim() : ""; if (!/^[a-f0-9]{32}$/.test(id)) { throw new Error("zoneId must be an exact Cloudflare zone ID"); } return id; } async function getZoneSetting(zoneId, setting, config) { const name = requireZoneSettingName(setting); const id = requireZoneId(zoneId); const result = await cfFetch(`/zones/${encodeURIComponent(id)}/settings/${name}`, { config }); if (!result || result.id !== name || typeof result.value !== "string" || !result.value.trim()) { throw new Error(`Cloudflare returned an invalid zone setting for ${name}`); } const value = result.value.trim().toLowerCase(); const allowed = CLOUDFLARE_ZONE_SETTINGS[name]; if (!allowed.includes(value)) { throw new Error(`Cloudflare returned unsupported ${name} value ${value}`); } return value; } async function ensureZoneSetting(zoneId, setting, value, config) { const name = requireZoneSettingName(setting); const requested = requireZoneSettingValue(name, value); const id = requireZoneId(zoneId); const current = await getZoneSetting(id, name, config); if (current === requested) return { setting: name, value: requested, changed: false }; await cfFetch(`/zones/${encodeURIComponent(id)}/settings/${name}`, { method: "PATCH", body: { value: requested }, config }); const readback = await getZoneSetting(id, name, config); if (readback !== requested) { throw new Error(`Cloudflare zone setting ${name} readback is ${readback}, expected ${requested}`); } return { setting: name, value: requested, changed: true }; } class CloudflareDnsPaginationError extends Error { constructor(reason, page, count, metadata) { const numeric = metadata ? Object.fromEntries(["page", "per_page", "count", "total_count", "total_pages"].map((key) => [key, typeof metadata[key] === "number" && Number.isFinite(metadata[key]) ? metadata[key] : null])) : null; super(`Cloudflare DNS pagination ${reason} (page=${page}, returned=${count}, metadata=${JSON.stringify(numeric)})`); this.name = "CloudflareDnsPaginationError"; } } var DNS_MAIL_RECONCILIATION_TIMEOUT_MS = 25000; async function listRecords(zoneId, config, options = {}) { const deadline = Math.min(performance.now() + 1e4, options.deadline ?? Infinity); if (!Number.isFinite(deadline)) throw new Error("Invalid DNS enumeration deadline"); const assertRead = async () => { await options.assertLease?.(); if (performance.now() >= deadline) throw new Error("Cloudflare DNS enumeration time budget exhausted"); }; for (let attempt = 1;; attempt++) { await assertRead(); try { const records = await listRecordsAttempt(zoneId, config, assertRead, deadline); await assertRead(); return records; } catch (error) { if (!(error instanceof CloudflareDnsPaginationError) || attempt >= 6) throw error; await assertRead(); const delay = 250 * 2 ** (attempt - 1); if (performance.now() + delay >= deadline) throw error; await new Promise((resolve) => setTimeout(resolve, delay)); } } } var CF_DNS_READ_TYPES = new Set(["A", "AAAA", "CAA", "CERT", "CNAME", "DNSKEY", "DS", "HTTPS", "LOC", "MX", "NAPTR", "NS", "OPENPGPKEY", "PTR", "SMIMEA", "SRV", "SSHFP", "SVCB", "TLSA", "TXT", "URI"]); function readDnsRecord(value) { const malformed = () => new Error("Cloudflare returned a malformed DNS record"); if (!value || typeof value !== "object" || Array.isArray(value)) throw malformed(); const r = value; if (typeof r.id !== "string" || !/^[A-Za-z0-9_-]{1,128}$/u.test(r.id) || typeof r.type !== "string" || !CF_DNS_READ_TYPES.has(r.type) || typeof r.name !== "string" || r.name.length > 253 || !/^(?:\*|[A-Za-z0-9_-]+)(?:\.[A-Za-z0-9_-]+)+\.?$/u.test(r.name) || typeof r.content !== "string" || r.content.length === 0 && r.type !== "TXT" || typeof r.ttl !== "number" || !Number.isSafeInteger(r.ttl) || !(r.ttl === 1 || r.ttl >= 30 && r.ttl <= 86400) || (r.type === "MX" || r.priority !== undefined) && (typeof r.priority !== "number" || !Number.isSafeInteger(r.priority) || r.priority < 0 || r.priority > 65535) || r.proxied !== undefined && typeof r.proxied !== "boolean") throw malformed(); return { id: r.id, type: r.type, name: r.name, content: r.content, ttl: r.ttl, priority: r.priority, proxied: r.proxied }; } async function listRecordsAttempt(zoneId, config, assertRead, deadline) { const records = []; let page = 1; const seenIds = new Set; let pagination; while (true) { await assertRead(); let resultInfo; const result = await cfFetch(`/zones/${zoneId}/dns_records?per_page=100&page=${page}`, { config, onResultInfo: (value) => { resultInfo = value; }, timeoutMs: Math.max(1, Math.ceil(deadline - performance.now())) }); await assertRead(); if (!Array.isArray(result)) throw new Error("Cloudflare DNS list result is not an array"); const pageRecords = result.map(readDnsRecord); for (const record of pageRecords) { if (seenIds.has(record.id)) throw new Error("Cloudflare DNS pagination repeated a record identity"); seenIds.add(record.id); } if (resultInfo !== undefined) { if (!resultInfo || typeof resultInfo !== "object") throw new Error("Cloudflare DNS pagination is malformed"); const info = resultInfo; const values = [info.page, info.per_page, info.count, info.total_count, info.total_pages]; if (!values.every((value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0)) throw new CloudflareDnsPaginationError("is incomplete", page, result.length, info); const current = { perPage: info.per_page, totalCount: info.total_count, totalPages: info.total_pages }; const expectedPages = Math.ceil(current.totalCount / current.perPage); if (info.page !== page || current.perPage < 1 || current.perPage > 100) throw new Error("Cloudflare DNS pagination has invalid page identity or size"); if (info.count !== result.length || current.totalPages !== expectedPages && !(current.totalCount === 0 && current.totalPages === 1) || result.length !== Math.min(current.perPage, Math.max(0, current.totalCount - (page - 1) * current.perPage)) || pagination && JSON.stringify(pagination) !== JSON.stringify(current)) throw new CloudflareDnsPaginationError("changed or contradicts the result", page, result.length, info); pagination = current; } else if (pagination) throw new CloudflareDnsPaginationError("disappeared before completion", page, result.length); records.push(...pageRecords); if (pagination ? page >= pagination.totalPages : result.length < 100) { if (pagination && records.length !== pagination.totalCount) throw new CloudflareDnsPaginationError("did not yield the complete population", page, result.length); break; } page++; } return records; } async function listRecordsByNameType(zoneId, type, name, config) { const result = await cfFetch(`/zones/${zoneId}/dns_records?type=${encodeURIComponent(type)}&name=${encodeURIComponent(name)}`, { config }); return (result ?? []).map((r) => ({ id: r.id, type: r.type, name: r.name, content: r.content, ttl: r.ttl, priority: r.priority, proxied: r.proxied })); } async function upsertRecord(zoneId, record, config) { const existing = await listRecordsByNameType(zoneId, record.type, record.name, config); const body = { type: record.type, name: record.name, content: record.content, ttl: record.ttl ?? 1, priority: record.priority, proxied: record.proxied ?? false }; const sameRecord = existing.find((r) => r.content === record.content && (r.priority ?? undefined) === (record.priority ?? undefined) && (r.proxied ?? false) === (record.proxied ?? false)); if (sameRecord?.id) { await cfFetch(`/zones/${zoneId}/dns_records/${sameRecord.id}`, { method: "PUT", body, config }); } else { await cfFetch(`/zones/${zoneId}/dns_records`, { method: "POST", body, config }); } } function sameRecordIdentity(a, b) { return a.type === b.type && a.name === b.name && a.content === b.content && (a.priority ?? undefined) === (b.priority ?? undefined); } function resolveDesiredProxied(records, existing) { const omitted = records.filter((record) => record.proxied === undefined); if (omitted.length === 0) return records; const { type, name } = records[0]; if (omitted.length === records.length) { const proxyStates = new Set(existing.map((record) => record.proxied)); if (proxyStates.size > 1) { throw new Error(`Cannot safely preserve Cloudflare proxied state for ${type} ${name}: ` + "existing records have mixed proxied values while desired records omit proxied; " + "set proxied explicitly on every desired record"); } const inherited = existing[0]?.proxied; return inherited === undefined ? records : records.map((record) => ({ ...record, proxied: inherited })); } const existingHasProxyState = existing.some((record) => record.proxied !== undefined); return records.map((record) => { if (record.proxied !== undefined) return record; const matches = existing.filter((candidate) => sameRecordIdentity(candidate, record)); if (matches.length === 1 && matches[0].proxied !== undefined) { return { ...record, proxied: matches[0].proxied }; } if (!existingHasProxyState) return record; throw new Error(`Cannot safely preserve Cloudflare proxied state for ${type} ${name} record ${record.content}: ` + "omitted proxied does not resolve to exactly one existing value; " + "set proxied explicitly on this desired record"); }); } async function prepareRecordsByNameType(zoneId, records, config) { if (records.length === 0) return; const { type, name } = records[0]; const existing = await listRecordsByNameType(zoneId, type, name, config); if (existing.length === records.length && records.every((record) => existing.some((candidate) => sameRecordIdentity(candidate, record) && candidate.ttl === record.ttl && (record.proxied === undefined || candidate.proxied === record.proxied)))) { return; } return { existing, desired: resolveDesiredProxied(records, existing) }; } async function replaceRecordsByNameType(zoneId, replacement, config) { for (const record of replacement.existing) { if (record.id) await deleteRecord(zoneId, record.id, config); } for (const record of replacement.desired) { await cfFetch(`/zones/${zoneId}/dns_records`, { method: "POST", body: { type: record.type, name: record.name, content: record.content, ttl: record.ttl ?? 1, priority: record.priority, ...record.proxied === undefined ? {} : { proxied: record.proxied } }, config }); } } async function reconcileRecords(zoneId, records, config) { const grouped = new Map; for (const record of records) { const key = `${record.type}|${record.name}`; const group = grouped.get(key) ?? []; group.push(record); grouped.set(key, group); } const replacements = []; for (const group of grouped.values()) { const replacement = await prepareRecordsByNameType(zoneId, group, config); if (replacement) replacements.push(replacement); } for (const replacement of replacements) { await replaceRecordsByNameType(zoneId, replacement, config); } } async function getDnsRecordGroupState(zoneId, type, name, config, options = {}) { const records = (await listRecords(zoneId, config, options)).filter((record) => record.type === type && record.name.toLowerCase().replace(/\.$/u, "") === name).map((record) => { if (!record.id) throw new Error("DNS state record has no provider identity"); return { id: record.id, type: record.type, name: record.name, value: record.content, ttl: record.ttl, priority: record.priority ?? null, proxied: record.proxied ?? null }; }).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0); if (records.length > 1000 || new Set(records.map((record) => record.id)).size !== records.length) { throw new Error("DNS state group is oversized or has ambiguous provider identities"); } const state = { hash_scheme: "domains-dns-group-v1", zone_id: zoneId, record_name: name, record_type: type, records }; return { ...state, records_sha256: createHash("sha256").update(JSON.stringify(state)).digest("hex") }; } async function reconcileGuardedRecords(zoneId, desired, expectedHash, config, assertLease = async () => {}) { const first = desired[0]; if (!first || desired.some((record) => record.type !== first.type || record.name !== first.name)) throw new Error("DNS guard requires exactly one record group"); await assertLease(); const before = await getDnsRecordGroupState(zoneId, first.type, first.name, config, { assertLease }); if (before.records_sha256 !== expectedHash) throw new Error("DNS current record state does not match expected_current_records_sha256"); const existing = before.records.map((record) => ({ id: record.id, type: record.type, name: record.name, content: record.value, ttl: record.ttl, ...record.priority === null ? {} : { priority: record.priority }, ...record.proxied === null ? {} : { proxied: record.proxied } })); const targets = resolveDesiredProxied(desired, existing); const matches = (actual, target) => sameRecordIdentity(actual, target) && actual.ttl === target.ttl && (target.proxied === undefined || actual.proxied === target.proxied); const remaining = [...existing]; const retained = []; const additions = []; for (const target of targets) { const index = remaining.findIndex((record) => matches(record, target)); if (index < 0) additions.push(target); else retained.push(remaining.splice(index, 1)[0]); } if (additions.some((record) => record.ttl === 1)) throw new Error("Guarded automatic TTL requires an exact retained provider record"); await assertLease(); if ((await getDnsRecordGroupState(zoneId, first.type, first.name, config, { assertLease })).records_sha256 !== expectedHash) throw new Error("DNS record group changed before mutation"); for (const record of remaining) { await assertLease(); await deleteRecord(zoneId, record.id, config); } for (const record of additions) { await assertLease(); await cfFetch(`/zones/${zoneId}/dns_records`, { method: "POST", body: record, config }); } await assertLease(); const after = await getDnsRecordGroupState(zoneId, first.type, first.name, config, { assertLease }); const actual = after.records.map((record) => ({ id: record.id, type: record.type, name: record.name, content: record.value, ttl: record.ttl, ...record.priority === null ? {} : { priority: record.priority }, ...record.proxied === null ? {} : { proxied: record.proxied } })); if (actual.length !== targets.length || !targets.every((target) => actual.some((record) => matches(record, target))) || !retained.every((original) => actual.some((record) => record.id === original.id && matches(record, original)))) { throw new Error("Guarded DNS complete record group readback mismatch"); } return after; } async function reconcileMailRecords(zoneId, records, config, assertLease = async () => {}, operationDeadline = performance.now() + DNS_MAIL_RECONCILIATION_TIMEOUT_MS) { const deadline = Math.min(operationDeadline, performance.now() + DNS_MAIL_RECONCILIATION_TIMEOUT_MS); const remaining = () => { const milliseconds = deadline - performance.now(); if (!Number.isFinite(milliseconds) || milliseconds < 1) throw new Error("DNS reconciliation time budget exhausted"); return Math.floor(milliseconds); }; const assertOperation = async () => { remaining(); await assertLease(); remaining(); }; const applied = []; for (const desired of records) { const all = await listRecords(zoneId, config, { assertLease: assertOperation, deadline }); const named = all.filter((record) => record.name.toLowerCase().replace(/\.$/u, "") === desired.name); const existing = named.filter((record) => record.type === desired.type); const kind = desired.type === "TXT" ? mailPolicyKind(desired.content) : null; const dmarc = kind === "dmarc"; const spf = kind === "spf"; const policies = existing.filter((record) => kind !== null && mailPolicyKind(record.content) === kind); if ((dmarc || spf) && policies.length > 1) throw new Error("Multiple existing mail policies require review"); if (dmarc && policies[0]) { applied.push(policies[0]); continue; } const exact = existing.find((record) => sameRecordIdentity(record, desired) && (desired.type !== "CNAME" || record.proxied === false)); if (exact) { applied.push(exact); continue; } if (spf && policies[0]) { const current = policies[0]; const tokens = decodeDnsTxt(current.content).trim().split(/\s+/u); const known = new Set(tokens.map((token) => token.toLowerCase())); const inserts = decodeDnsTxt(desired.content).trim().split(/\s+/u).slice(1).filter((token) => !/^[+?~-]?all$/iu.test(token) && !known.has(token.toLowerCase())); if (inserts.some((token) => !/^include:[a-z0-9.-]+$/iu.test(token))) { throw new Error("Mail preservation accepts only additional SPF includes"); } if (inserts.length === 0) { applied.push(current); continue; } const terminal = tokens.findIndex((token) => /^[+?~-]?all$/iu.test(token)); tokens.splice(terminal < 0 ? tokens.length : terminal, 0, ...inserts); if (!current.id) throw new Error("Existing SPF record has no provider identity"); const updated = { ...current, content: tokens.join(" ") }; await assertOperation(); const fresh = await listRecords(zoneId, config, { assertLease: assertOperation, deadline }); const freshPolicies = fresh.filter((record) => record.type === "TXT" && record.name === current.name && mailPolicyKind(record.content) === "spf"); const same = freshPolicies.length === 1 ? freshPolicies.find((record) => record.id === current.id) : undefined; if (!same || JSON.stringify(same) !== JSON.stringify(current)) throw new Error("SPF record changed during reconciliation"); await assertOperation(); await cfFetch(`/zones/${zoneId}/dns_records/${current.id}`, { method: "PATCH", body: { content: updated.content }, config, timeoutMs: remaining() }); await assertOperation(); applied.push(updated); continue; } if (desired.type !== "TXT" && existing.length > 0) throw new Error("Existing mail record conflicts with requested authorization"); if (named.some((record) => record.type === "CNAME") && desired.type !== "CNAME" && named.some((record) => record.type === "CNAME" && record.proxied !== true)) { throw new Error("Mail record conflicts with an existing CNAME"); } if (desired.type === "CNAME" && named.length > 0) throw new Error("Mail CNAME conflicts with existing records"); await assertOperation(); await cfFetch(`/zones/${zoneId}/dns_records`, { method: "POST", body: desired, config, timeoutMs: remaining() }); await assertOperation(); applied.push(desired); } return applied; } async function deleteRecord(zoneId, recordId, config) { await cfFetch(`/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE", config }); } async function deleteRecordByNameType(zoneId, name, type, config) { const existing = await cfFetch(`/zones/${zoneId}/dns_records?type=${type}&name=${encodeURIComponent(name)}`, { config }); for (const r of existing ?? []) { await deleteRecord(zoneId, r.id, config); } } function zoneToDomainInfo(zone) { return { domain: zone.name, registrar: "Cloudflare DNS", created: "", expires: "", nameservers: zone.nameservers, status: zone.status === "active" ? "active" : "discovered", auto_renew: false }; } function withCloudflareMetadata(existing, zone) { return { ...existing, cloudflare: { zone_id: zone.id, zone_status: zone.status, source: "cloudflare:zones", synced_at: new Date().toISOString() } }; } function createCloudflareProvider(config) { const cfg = config ?? getConfig(); return { name: "cloudflare", dnsWriteScope: "changed-groups", async listDomains() { const zones = await listZones(cfg); return zones.map(zoneToDomainInfo); }, async syncToLocalDb(dbFns) { const zones = await listZones(cfg); let synced = 0; let created = 0; let updated = 0; const errors = []; for (const zone of zones) { try { const info = zoneToDomainInfo(zone); const existing = await dbFns.getDomainByName(zone.name); if (existing) { await dbFns.updateDomain(existing.id, { ...existing.registrar === "Cloudflare DNS" ? { registrar: null } : {}, status: existing.status === "discovered" && info.status === "active" ? "active" : existing.status, nameservers: zone.nameservers, metadata: withCloudflareMetadata(existing.metadata, zone) }); updated++; } else { await dbFns.createDomain({ name: zone.name, status: info.status === "active" ? "active" : "discovered", auto_renew: false, nameservers: zone.nameservers, notes: "Discovered from Cloudflare zones; registrar ownership was not inferred.", metadata: withCloudflareMetadata({}, zone) }); created++; } synced++; } catch (err) { errors.push(`${zone.name}: ${err instanceof Error ? err.message : String(err)}`); } } return { synced, created, updated, errors }; }, async getDnsRecords(domain) { const zone = await getZone(domain, cfg); if (!zone) return []; const records = await listRecords(zone.id, cfg); return records.map((r) => ({ type: r.type, name: r.name, value: r.content, ttl: r.ttl === 1 ? 0 : r.ttl, priority: r.priority, ...r.proxied === undefined ? {} : { proxied: r.proxied } })); }, async setDnsRecords(domain, records) { const zone = await getZone(domain, cfg); if (!zone) throw new Error(`No Cloudflare zone found for ${domain}`); await reconcileRecords(zone.id, records.map((r) => ({ type: r.type, name: r.name, content: r.value, ttl: r.ttl || 1, priority: r.priority, ...r.proxied === undefined ? {} : { proxied: r.proxied } })), cfg); return true; }, async deleteDnsRecords(domain, records) { const zone = await getZone(domain, cfg); if (!zone) throw new Error(`No Cloudflare zone found for ${domain}`); const grouped = new Map; for (const record of records) { const key = `${record.type}|${record.name}`; const group = grouped.get(key) ?? []; group.push(record); grouped.set(key, group); } for (const group of grouped.values()) { const { type, name } = group[0]; const existing = await listRecordsByNameType(zone.id, type, name, cfg); for (const record of group) { const match = existing.find((candidate) => candidate.content === record.value && (candidate.priority ?? undefined) === (record.priority ?? undefined)); if (match?.id) { await deleteRecord(zone.id, match.id, cfg); } } } return true; } }; } async function listWorkerCustomDomains(config) { const cfg = config ?? getConfig(); if (!cfg.accountId) throw new Error("CLOUDFLARE_ACCOUNT_ID is required for Worker Custom Domains."); return cfFetch(`/accounts/${encodeURIComponent(cfg.accountId)}/workers/domains`, { config: cfg }); } async function bindWorkerCustomDomain(hostname, zoneId, workerName = "shortlink-router", config) { const cfg = config ?? getConfig(); if (!cfg.accountId) throw new Error("CLOUDFLARE_ACCOUNT_ID is required for Worker Custom Domains."); if (!zoneId.trim()) throw new Error("Cloudflare zone id is required for Worker Custom Domains."); if (!/^[A-Za-z0-9_-]{1,64}$/.test(workerName)) throw new Error("Invalid Cloudflare Worker name."); return cfFetch(`/accounts/${encodeURIComponent(cfg.accountId)}/workers/domains`, { method: "PUT", body: { hostname, service: workerName, zone_id: zoneId }, config: cfg }); } async function workerCustomDomainReady(hostname, zoneId, workerName = "shortlink-router", config, fetchImpl = fetch, readiness = {}) { const domains = await listWorkerCustomDomains(config); const bound = domains.some((domain) => domain.hostname.toLowerCase() === hostname.toLowerCase() && domain.service === workerName && (domain.zone_id === undefined || domain.zone_id === zoneId) && domain.enabled !== false); if (!bound) return false; try { const path = readiness.path ?? "/.well-known/shortlink-router"; const header = readiness.header ?? "x-shortlink-router"; const expectedValue = readiness.value ?? workerName; const response = await fetchImpl(`https://${hostname}${path}`, { method: "GET", redirect: "manual", headers: { accept: "application/json" } }); const ready = response.status === 200 && response.headers.get(header) === expectedValue; await response.body?.cancel(); return ready; } catch { return false; } } export { CLOUDFLARE_ZONE_SETTINGS, CloudflareApiError, CloudflareDnsPaginationError, DNS_MAIL_RECONCILIATION_TIMEOUT_MS, bindWorkerCustomDomain, boundedResponseText, createCloudflareProvider, createRecordIfAbsent, createZone, deleteRecord, deleteRecordByNameType, deleteZone, ensureZone, ensureZoneOriginTlsMode, ensureZoneSetting, getConfig, getDnsRecordGroupState, getZone, getZoneOriginTlsMode, getZoneSetting, listRecords, listWorkerCustomDomains, listZones, reconcileGuardedRecords, reconcileMailRecords, reconcileRecords, updateRecordInPlace, upsertRecord, workerCustomDomainReady };