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" });
}
+48
View File
@@ -0,0 +1,48 @@
import { env } from "../env.js";
async function coolifyFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
if (!env.COOLIFY_API_TOKEN || !env.COOLIFY_BASE_URL) {
throw new Error("Coolify not configured (COOLIFY_API_TOKEN / COOLIFY_BASE_URL missing)");
}
const res = await fetch(`${env.COOLIFY_BASE_URL}${path}`, {
...options,
headers: {
Authorization: `Bearer ${env.COOLIFY_API_TOKEN}`,
"Content-Type": "application/json",
...(options.headers ?? {}),
},
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Coolify API ${res.status}: ${body.slice(0, 200)}`);
}
return res.json() as Promise<T>;
}
// Coolify only regenerates an app's Traefik labels from its fqdn field on
// deploy — patching fqdn alone leaves routing unchanged, so every domain
// add is followed by a restart (coollabsio/coolify#6281).
export async function addDomainToWebApp(hostname: string): Promise<void> {
const appUuid = env.COOLIFY_WEB_APP_UUID;
if (!appUuid) throw new Error("COOLIFY_WEB_APP_UUID not configured");
const app = await coolifyFetch<{ fqdn: string | null }>(`/api/v1/applications/${appUuid}`);
const existing = (app.fqdn ?? "")
.split(",")
.map((d) => d.trim())
.filter(Boolean);
const newUrl = `https://${hostname}`;
if (existing.includes(newUrl)) return;
const updated = [...existing, newUrl].join(",");
await coolifyFetch(`/api/v1/applications/${appUuid}`, {
method: "PATCH",
body: JSON.stringify({ domains: updated }),
});
await coolifyFetch(`/api/v1/applications/${appUuid}/restart`, { method: "POST" });
}