Implement Cloudflare custom domain management flow

- Added support for connecting, refreshing, and removing custom domains through Cloudflare.
- Updated API routes and validation schemas to handle new domain actions.
- Refactored domain management logic in the dashboard to accommodate Cloudflare integration.
- Enhanced user experience with clear status messages and DNS instructions for domain setup.
- Updated translations and UI components to reflect new domain management features.
This commit is contained in:
Rotimi Best
2026-03-27 09:12:53 +01:00
parent 3919ff6c2f
commit f3d212f984
23 changed files with 1376 additions and 244 deletions
+4
View File
@@ -8,6 +8,10 @@ const envSchema = z.object({
CLOUDFLARE_RENDERING_API_KEY: z.string().optional(),
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_IMAGE_BUCKET_DOMAIN: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN: z.string().optional(),
// S3-compatible storage (MinIO, AWS S3, or other S3-compatible backends)
OBJECT_STORAGE_ENDPOINT: z.string().optional(),
OBJECT_STORAGE_REGION: z.string().optional(),
+42 -31
View File
@@ -1,49 +1,60 @@
import { addDomainToVercel, getConfigResponse, removeDomainFromVercelProject } from '@api/services/org/domain';
import { ZDomainActionRequest } from '@cio/utils/validation/organization';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import {
assertSupportedCustomDomain,
connectDomain,
normalizeCustomDomain,
refreshDomain,
removeDomain
} from '@api/services/org/domain';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { updateOrg } from '@api/services/organization';
import { handleError } from '@api/utils/errors';
import { z } from 'zod';
const ZDomainRequest = z.object({
params: z.object({
key: z.enum(['verify_domain', 'add_domain', 'remove_domain']),
domain: z.string().min(1)
})
});
import { zValidator } from '@hono/zod-validator';
export const domainRouter = new Hono()
/**
* POST /domain
* Handles domain operations: verify, add, or remove domain
* Requires authentication
* Handles domain operations: connect, refresh, or remove
* Requires authentication and organization admin access
*/
.post('/', authMiddleware, async (c) => {
.post('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZDomainActionRequest), async (c) => {
try {
const body = await c.req.json();
const validatedData = ZDomainRequest.parse(body);
const orgId = c.req.header('cio-org-id')!;
const { action, domain } = c.req.valid('json');
const { key, domain } = validatedData.params;
const normalizedDomain = normalizeCustomDomain(domain);
assertSupportedCustomDomain(normalizedDomain);
if (domain.includes('classroomio.com')) {
return c.json({ success: false }, 400);
}
switch (action) {
case 'connect': {
const result = await connectDomain(normalizedDomain);
await updateOrg(orgId, {
customDomain: normalizedDomain,
isCustomDomainVerified: result.verified
});
switch (key) {
case 'verify_domain': {
const configResponse = await getConfigResponse(domain);
return c.json({ success: true, verified: !configResponse.misconfigured }, 200);
return c.json({ success: true, data: result }, 200);
}
case 'add_domain': {
const addDomainResponse = await addDomainToVercel(domain);
return c.json({ success: true, data: addDomainResponse }, 200);
case 'refresh': {
const result = await refreshDomain(normalizedDomain);
await updateOrg(orgId, {
customDomain: normalizedDomain,
isCustomDomainVerified: result.verified
});
return c.json({ success: true, data: result }, 200);
}
case 'remove_domain': {
const removeDomainResponse = await removeDomainFromVercelProject(domain);
return c.json({ success: true, data: removeDomainResponse }, 200);
case 'remove': {
const result = await removeDomain(normalizedDomain);
await updateOrg(orgId, {
customDomain: null,
isCustomDomainVerified: false
});
return c.json({ success: true, data: result }, 200);
}
default:
return c.json({ success: false, message: 'Invalid request' }, 400);
}
} catch (error) {
return handleError(c, error, 'Failed to process domain request');
+317 -40
View File
@@ -1,48 +1,325 @@
// Code from https://github.com/vercel/platforms/blob/1ed55b13bcae97b037c69ec40b4c32df21c2412c/lib/domains.ts
import { env } from '@api/config/env';
import { AppError, ErrorCodes } from '@api/utils/errors';
export const addDomainToVercel = async (domain: string) => {
return await fetch(
`https://api.vercel.com/v10/projects/${env.PROJECT_ID_VERCEL}/domains${
env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : ''
}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: domain
})
export type DomainSetupStatus =
| 'reconnect_required'
| 'pending_dns'
| 'pending_verification'
| 'verified'
| 'removed'
| 'error';
export interface DomainDnsRecord {
type: 'CNAME' | 'TXT';
name: string;
value: string;
status: 'pending' | 'active';
}
export interface DomainSetupResult {
hostname: string;
status: DomainSetupStatus;
verified: boolean;
reconnectRequired: boolean;
message: string;
provider: 'cloudflare';
dnsRecords: DomainDnsRecord[];
}
interface CloudflareApiError {
code: number;
message: string;
}
interface CloudflareEnvelope<T> {
success: boolean;
errors: CloudflareApiError[];
messages: Array<{ code: number; message: string }>;
result: T;
}
interface CloudflareValidationRecord {
status?: string;
txt_name?: string;
txt_value?: string;
}
interface CloudflareCustomHostname {
id: string;
hostname: string;
status?: string;
ownership_verification?: {
type?: string;
name?: string;
value?: string;
};
ssl?: {
status?: string;
validation_records?: CloudflareValidationRecord[];
};
}
const SUPPORTED_CUSTOM_DOMAIN_PATTERN = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){2,}[a-z]{2,63}$/i;
function ensureCloudflareConfig() {
const missing = [
['CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN', env.CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN],
['CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID', env.CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID],
['CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET', env.CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET]
].filter(([, value]) => !value);
if (missing.length > 0) {
throw new AppError(
`Missing Cloudflare custom domain config: ${missing.map(([key]) => key).join(', ')}`,
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
function getCloudflareBasePath() {
ensureCloudflareConfig();
return `https://api.cloudflare.com/client/v4/zones/${env.CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID}/custom_hostnames`;
}
async function cloudflareRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${getCloudflareBasePath()}${path}`, {
...init,
headers: {
Authorization: `Bearer ${env.CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN}`,
'Content-Type': 'application/json',
...(init.headers ?? {})
}
).then((res) => res.json());
};
});
export const removeDomainFromVercelProject = async (domain: string) => {
return await fetch(
`https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${domain}${
env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : ''
}`,
{
headers: {
Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`
},
method: 'DELETE'
const payload = (await response.json()) as CloudflareEnvelope<T>;
if (!response.ok || !payload.success) {
const message =
payload.errors?.map((error) => error.message).join(', ') ||
payload.messages?.map((item) => item.message).join(', ') ||
'Cloudflare domain request failed';
throw new AppError(
message,
response.status === 409 ? ErrorCodes.CONFLICT : ErrorCodes.INTERNAL_ERROR,
(response.status || 500) as 400 | 401 | 403 | 404 | 409 | 422 | 500
);
}
return payload.result;
}
function dedupeDnsRecords(records: DomainDnsRecord[]) {
const seen = new Set<string>();
return records.filter((record) => {
const key = `${record.type}:${record.name}:${record.value}`;
if (seen.has(key)) {
return false;
}
).then((res) => res.json());
};
export const getConfigResponse = async (domain: string) => {
return await fetch(
`https://api.vercel.com/v6/domains/${domain}/config${env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : ''}`,
seen.add(key);
return true;
});
}
function buildDnsRecords(hostname: string, customHostname?: CloudflareCustomHostname | null): DomainDnsRecord[] {
const records: DomainDnsRecord[] = [
{
method: 'GET',
headers: {
Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`,
'Content-Type': 'application/json'
type: 'CNAME',
name: hostname,
value: env.CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET!,
status: customHostname?.status === 'active' ? 'active' : 'pending'
}
];
if (customHostname?.ownership_verification?.name && customHostname?.ownership_verification?.value) {
records.push({
type: 'TXT',
name: customHostname.ownership_verification.name,
value: customHostname.ownership_verification.value,
status: customHostname.status === 'active' ? 'active' : 'pending'
});
}
customHostname?.ssl?.validation_records?.forEach((record) => {
if (!record.txt_name || !record.txt_value) {
return;
}
records.push({
type: 'TXT',
name: record.txt_name,
value: record.txt_value,
status: record.status === 'active' ? 'active' : 'pending'
});
});
return dedupeDnsRecords(records);
}
function mapDomainStatus(customHostname?: CloudflareCustomHostname | null): DomainSetupStatus {
if (!customHostname) {
return 'reconnect_required';
}
if (customHostname.status === 'active' || customHostname.ssl?.status === 'active') {
return 'verified';
}
const hasOwnershipRecord = Boolean(
customHostname.ownership_verification?.name && customHostname.ownership_verification?.value
);
if (hasOwnershipRecord) {
return 'pending_dns';
}
const hasSslValidationRecords = Boolean(
customHostname.ssl?.validation_records?.some((record) => record.txt_name && record.txt_value)
);
if (hasSslValidationRecords || customHostname.ssl?.status) {
return 'pending_verification';
}
return 'error';
}
function getStatusMessage(status: DomainSetupStatus) {
switch (status) {
case 'reconnect_required':
return 'Reconnect this custom domain to generate new Cloudflare setup records.';
case 'pending_dns':
return 'Add the DNS records below, then refresh verification.';
case 'pending_verification':
return 'DNS records were found. SSL issuance or validation is still in progress.';
case 'verified':
return 'Custom domain verified.';
case 'removed':
return 'Custom domain removed.';
case 'error':
default:
return 'Custom domain exists, but verification is not complete yet.';
}
}
function toDomainSetupResult(hostname: string, customHostname?: CloudflareCustomHostname | null): DomainSetupResult {
const status = mapDomainStatus(customHostname);
return {
hostname,
status,
verified: status === 'verified',
reconnectRequired: status === 'reconnect_required',
message: getStatusMessage(status),
provider: 'cloudflare',
dnsRecords: status === 'reconnect_required' ? [] : buildDnsRecords(hostname, customHostname)
};
}
export function normalizeCustomDomain(domain: string) {
return domain
.trim()
.toLowerCase()
.replace(/^https?:\/\//i, '')
.split('/')[0]
.replace(/\.$/, '');
}
export function assertSupportedCustomDomain(domain: string) {
if (!SUPPORTED_CUSTOM_DOMAIN_PATTERN.test(domain)) {
throw new AppError(
'Only custom subdomains are supported. Use a subdomain like courses.yourwebsite.com.',
ErrorCodes.VALIDATION_ERROR,
400,
'domain'
);
}
if (domain.includes('classroomio.com')) {
throw new AppError("Domain cannot contain 'classroomio'", ErrorCodes.VALIDATION_ERROR, 400, 'domain');
}
}
async function getCustomHostnameByDomain(hostname: string) {
const results = await cloudflareRequest<CloudflareCustomHostname[]>(`?hostname=${encodeURIComponent(hostname)}`, {
method: 'GET'
});
return results.find((result) => result.hostname.toLowerCase() === hostname.toLowerCase()) ?? null;
}
async function createCustomHostname(hostname: string) {
const body: Record<string, unknown> = {
hostname,
ssl: {
method: 'txt',
type: 'dv'
}
};
if (env.CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN) {
body.custom_origin_server = env.CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN;
}
return cloudflareRequest<CloudflareCustomHostname>('', {
method: 'POST',
body: JSON.stringify(body)
});
}
async function revalidateCustomHostname(customHostname: CloudflareCustomHostname) {
return cloudflareRequest<CloudflareCustomHostname>(`/${customHostname.id}`, {
method: 'PATCH',
body: JSON.stringify({
ssl: {
method: 'txt',
type: 'dv'
}
}
).then((res) => res.json());
};
})
});
}
export async function connectDomain(hostname: string): Promise<DomainSetupResult> {
const existing = await getCustomHostnameByDomain(hostname);
const customHostname = existing ?? (await createCustomHostname(hostname));
return toDomainSetupResult(hostname, customHostname);
}
export async function refreshDomain(hostname: string): Promise<DomainSetupResult> {
const existing = await getCustomHostnameByDomain(hostname);
if (!existing) {
return toDomainSetupResult(hostname, null);
}
if (existing.status === 'active' || existing.ssl?.status === 'active') {
return toDomainSetupResult(hostname, existing);
}
const refreshed = await revalidateCustomHostname(existing);
return toDomainSetupResult(hostname, refreshed);
}
export async function removeDomain(hostname: string): Promise<DomainSetupResult> {
const existing = await getCustomHostnameByDomain(hostname);
if (existing) {
await cloudflareRequest<null>(`/${existing.id}`, {
method: 'DELETE'
});
}
return {
hostname,
status: 'removed',
verified: false,
reconnectRequired: false,
message: getStatusMessage('removed'),
provider: 'cloudflare',
dnsRecords: []
};
}