feat(api): automate custom domain setup via Cloudflare + Coolify

Adding a custom domain now:
- creates a Cloudflare Custom Hostname (SSL for SaaS) automatically,
  returning the ownership + SSL validation TXT records to show the
  restaurant owner alongside the CNAME instruction
- adds the domain to the Coolify app's fqdn and triggers a restart
  (Coolify only regenerates Traefik labels on deploy, not on a bare
  fqdn PATCH — coollabsio/coolify#6281)
- /verify now checks Cloudflare's actual ssl.status instead of doing
  a DNS CNAME lookup, which is structurally blind on proxied hostnames

Tested end-to-end against the real Cloudflare zone and Coolify app
(create, verify data, then clean up) before this push — see
docs/PROGRESS.md.

Requires CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID, COOLIFY_API_TOKEN,
COOLIFY_BASE_URL, COOLIFY_WEB_APP_UUID in the API's environment.
Without them, domain add/verify falls back to DB-only bookkeeping.
This commit is contained in:
AyrisAI
2026-08-20 05:43:40 +03:00
parent bb27c3f773
commit 7b9cfb6858
4 changed files with 169 additions and 58 deletions
+60
View File
@@ -0,0 +1,60 @@
import { env } from "../env.js";
const CF_API = "https://api.cloudflare.com/client/v4";
export interface CloudflareCustomHostname {
id: string;
hostname: string;
status: string;
ownership_verification?: { type: string; name: string; value: string };
ssl: {
status: string;
method: string;
type: string;
validation_records?: { txt_name: string; txt_value: string }[];
};
}
async function cfFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
if (!env.CLOUDFLARE_API_TOKEN || !env.CLOUDFLARE_ZONE_ID) {
throw new Error("Cloudflare not configured (CLOUDFLARE_API_TOKEN / CLOUDFLARE_ZONE_ID missing)");
}
const res = await fetch(`${CF_API}${path}`, {
...options,
headers: {
Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`,
"Content-Type": "application/json",
...(options.headers ?? {}),
},
});
const json = (await res.json()) as { success: boolean; result: T; errors: { message: string }[] };
if (!json.success) {
throw new Error(json.errors?.[0]?.message ?? "Cloudflare API error");
}
return json.result;
}
// One-time DCV (domain ownership) TXT + a per-hostname SSL cert via
// Cloudflare for SaaS. The hostname's own CNAME (routing) is set by the
// customer independently — see domains.ts's cnameTarget instruction.
export async function createCustomHostname(hostname: string): Promise<CloudflareCustomHostname> {
return cfFetch<CloudflareCustomHostname>(`/zones/${env.CLOUDFLARE_ZONE_ID}/custom_hostnames`, {
method: "POST",
body: JSON.stringify({ hostname, ssl: { method: "txt", type: "dv" } }),
});
}
export async function getCustomHostnameByHostname(
hostname: string,
): Promise<CloudflareCustomHostname | null> {
const results = await cfFetch<CloudflareCustomHostname[]>(
`/zones/${env.CLOUDFLARE_ZONE_ID}/custom_hostnames?hostname=${encodeURIComponent(hostname)}`,
);
return results?.[0] ?? null;
}
export async function deleteCustomHostname(id: string): Promise<void> {
await cfFetch(`/zones/${env.CLOUDFLARE_ZONE_ID}/custom_hostnames/${id}`, { method: "DELETE" });
}