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
+5
View File
@@ -16,6 +16,11 @@ const envSchema = z.object({
PUBLIC_WEB_URL: z.string().default(process.env.PUBLIC_WEB_URL ?? "http://localhost:3000"),
GEMINI_API_KEY: z.string().optional(),
OPENAI_API_KEY: z.string().optional(),
CLOUDFLARE_API_TOKEN: z.string().optional(),
CLOUDFLARE_ZONE_ID: z.string().optional(),
COOLIFY_API_TOKEN: z.string().optional(),
COOLIFY_BASE_URL: z.string().optional(),
COOLIFY_WEB_APP_UUID: z.string().optional(),
});
export const env = envSchema.parse(process.env);
+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" });
}
+56 -58
View File
@@ -1,9 +1,10 @@
import dns from "node:dns/promises";
import type { FastifyPluginAsync } from "fastify";
import { z } from "zod";
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
import { env } from "../env.js";
import { supabase } from "../lib/supabase.js";
import { addDomainToWebApp } from "../lib/coolify.js";
import { createCustomHostname, getCustomHostnameByHostname } from "../lib/cloudflare.js";
const addDomainSchema = z.object({
hostname: z
@@ -13,14 +14,6 @@ const addDomainSchema = z.object({
.transform((val) => val.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "")),
});
const resolver = new dns.Resolver();
// Use public authoritative DNS servers to check live global records
try {
resolver.setServers(["8.8.8.8", "1.1.1.1"]);
} catch {
// Fallback to default
}
export const domainsRoutes: FastifyPluginAsync = async (app) => {
// Get all domains for a restaurant
app.get("/restaurants/:id/domains", async (req, reply) => {
@@ -54,7 +47,8 @@ export const domainsRoutes: FastifyPluginAsync = async (app) => {
});
});
// Add a custom domain
// Add a custom domain — registers it with Cloudflare (SSL for SaaS) and
// the origin (Coolify) so it's routable/certifiable, not just recorded.
app.post("/restaurants/:id/domains", async (req, reply) => {
const userId = await requireAuth(req, reply);
if (!userId || !supabase) return;
@@ -70,7 +64,6 @@ export const domainsRoutes: FastifyPluginAsync = async (app) => {
const { hostname } = parsed.data;
// Check if domain is already registered
const { data: existing } = await supabase
.from("domains")
.select("id, restaurant_id")
@@ -101,19 +94,44 @@ export const domainsRoutes: FastifyPluginAsync = async (app) => {
}
const cnameTarget = `cname.${env.ROOT_DOMAIN}`;
const warnings: string[] = [];
let dcvTxt: { name: string; value: string } | null = null;
let sslTxt: { name: string; value: string }[] = [];
try {
const hostnameRecord = await createCustomHostname(hostname);
if (hostnameRecord.ownership_verification) {
dcvTxt = { name: hostnameRecord.ownership_verification.name, value: hostnameRecord.ownership_verification.value };
}
sslTxt = (hostnameRecord.ssl.validation_records ?? []).map((r) => ({ name: r.txt_name, value: r.txt_value }));
} catch (err) {
req.log.warn({ err, hostname }, "Cloudflare custom hostname creation failed");
warnings.push("Cloudflare kaydı oluşturulamadı, manuel kurulum gerekebilir.");
}
try {
await addDomainToWebApp(hostname);
} catch (err) {
req.log.warn({ err, hostname }, "Coolify domain add failed");
warnings.push("Sunucu routing'i otomatik eklenemedi, manuel kurulum gerekebilir.");
}
return reply.status(201).send({
domain: newDomain,
cnameTarget,
instructions: {
type: "CNAME",
host: hostname,
target: cnameTarget,
cname: { type: "CNAME", host: hostname, target: cnameTarget },
ownershipTxt: dcvTxt,
sslTxt,
},
warnings,
});
});
// REAL DNS Verification (Checks real CNAME records across 8.8.8.8 and 1.1.1.1)
// Verification checks Cloudflare's own Custom Hostname SSL status — a
// plain DNS lookup can't see a proxied domain's real CNAME target, so
// that approach always failed for hostnames set up correctly (see
// docs/PROGRESS.md, 2026-08-20).
app.post("/restaurants/:id/domains/:domainId/verify", async (req, reply) => {
const userId = await requireAuth(req, reply);
if (!userId || !supabase) return;
@@ -133,41 +151,19 @@ export const domainsRoutes: FastifyPluginAsync = async (app) => {
return reply.code(404).send({ message: "domain not found" });
}
const expectedTarget = `cname.${env.ROOT_DOMAIN}`;
let isVerified = false;
let dnsErrorDetails = "";
let cfHostname: Awaited<ReturnType<typeof getCustomHostnameByHostname>> = null;
try {
// Real DNS lookup for CNAME
const cnames = await resolver.resolveCname(domain.hostname);
req.log.info({ hostname: domain.hostname, foundCnames: cnames }, "Real DNS CNAME check");
isVerified = cnames.some(
(c) =>
c.toLowerCase().includes(env.ROOT_DOMAIN.toLowerCase()) ||
c.toLowerCase().includes(`cname.${env.ROOT_DOMAIN}`.toLowerCase()) ||
c.toLowerCase().includes("menul.io"),
);
if (!isVerified) {
dnsErrorDetails = `Bulunan CNAME: ${cnames.join(", ")} (Beklenen: ${expectedTarget})`;
}
} catch (err: any) {
req.log.warn({ hostname: domain.hostname, err: err?.message || err }, "DNS CNAME lookup failed");
if (err?.code === "ENOTFOUND" || err?.code === "ENODATA") {
dnsErrorDetails = "DNS kaydı bulunamadı. Henüz CNAME yönlendirmesi yapılmamış veya DNS yayılımı (propagation) tamamlanmamış.";
} else {
dnsErrorDetails = err?.message || "DNS sorgusu başarısız oldu.";
}
cfHostname = await getCustomHostnameByHostname(domain.hostname);
} catch (err) {
req.log.warn({ err, hostname: domain.hostname }, "Cloudflare status check failed");
}
const isVerified = cfHostname?.status === "active" && cfHostname?.ssl?.status === "active";
if (isVerified) {
const { data: updated } = await supabase
.from("domains")
.update({
status: "verified",
verified_at: new Date().toISOString(),
})
.update({ status: "verified", verified_at: new Date().toISOString() })
.eq("id", domainId)
.select()
.single();
@@ -175,29 +171,31 @@ export const domainsRoutes: FastifyPluginAsync = async (app) => {
return reply.send({
verified: true,
domain: updated,
message: `Tebrikler! ${domain.hostname} alan adının CNAME kaydı doğrulandı ve menünüze bağlandı. 🎉`,
message: `Tebrikler! ${domain.hostname} alan adı doğrulandı ve menünüze bağlandı. 🎉`,
});
}
// A DNS CNAME lookup structurally cannot see the real target once the
// record is proxied (Cloudflare hides it behind its own edge IPs) — so a
// failed re-check here is inconclusive, not proof the domain broke.
// Never downgrade a domain that was already verified; only a first-time
// check is allowed to land on "pending".
// Never downgrade an already-verified domain on an inconclusive re-check.
if (domain.status !== "verified") {
await supabase
.from("domains")
.update({
status: "pending",
verified_at: null,
})
.eq("id", domainId);
await supabase.from("domains").update({ status: "pending", verified_at: null }).eq("id", domainId);
}
return reply.send({
verified: domain.status === "verified",
domain: domain.status === "verified" ? domain : { ...domain, status: "pending", verified_at: null },
message: `Doğrulanamadı: ${dnsErrorDetails}\n\nLütfen alan adı yönetim panelinizden (Cloudflare, GoDaddy, Natro vb.) ${domain.hostname} için CNAME kaydını ${expectedTarget} adresine yönlendirin.`,
cloudflareStatus: cfHostname
? {
hostnameStatus: cfHostname.status,
sslStatus: cfHostname.ssl?.status,
ownershipTxt: cfHostname.ownership_verification
? { name: cfHostname.ownership_verification.name, value: cfHostname.ownership_verification.value }
: null,
sslTxt: (cfHostname.ssl?.validation_records ?? []).map((r) => ({ name: r.txt_name, value: r.txt_value })),
}
: null,
message: cfHostname
? `Henüz aktif değil (hostname: ${cfHostname.status}, sertifika: ${cfHostname.ssl?.status}). DNS kayıtlarının işlenmesi birkaç dakika sürebilir.`
: `${domain.hostname} Cloudflare'de bulunamadı. Domain eklerken bir hata oluşmuş olabilir, tekrar eklemeyi deneyin.`,
});
});