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:
@@ -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" });
|
||||
}
|
||||
Reference in New Issue
Block a user