From f3d212f984491028204f49830cbbafdae532c0bd Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Fri, 27 Mar 2026 09:12:53 +0100 Subject: [PATCH] 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. --- apps/api/src/config/env.ts | 4 + apps/api/src/routes/domain/domain.ts | 73 ++-- apps/api/src/services/org/domain.ts | 357 ++++++++++++++++-- .../src/lib/features/org/api/org.svelte.ts | 14 +- .../src/lib/features/org/utils/types.ts | 2 + .../features/settings/pages/domains.svelte | 322 ++++++++++------ .../src/lib/utils/functions/domain.ts | 20 +- .../src/lib/utils/translations/da.json | 44 ++- .../src/lib/utils/translations/de.json | 44 ++- .../src/lib/utils/translations/en.json | 42 +++ .../src/lib/utils/translations/es.json | 44 ++- .../src/lib/utils/translations/fr.json | 44 ++- .../src/lib/utils/translations/hi.json | 44 ++- .../src/lib/utils/translations/pl.json | 44 ++- .../src/lib/utils/translations/pt.json | 44 ++- .../src/lib/utils/translations/ru.json | 44 ++- .../src/lib/utils/translations/vi.json | 44 ++- apps/dashboard/src/routes/+error.svelte | 50 ++- .../course/[slug]/enroll/+page.server.ts | 20 +- .../routes/invite/[hash]/+layout.server.ts | 8 +- .../src/validation/organization/domain.ts | 11 + .../src/validation/organization/index.ts | 1 + prd/custom-domain-cloudflare/README.md | 300 +++++++++++++++ 23 files changed, 1376 insertions(+), 244 deletions(-) create mode 100644 packages/utils/src/validation/organization/domain.ts create mode 100644 prd/custom-domain-cloudflare/README.md diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 9b0d4c4a..6a21969c 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -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(), diff --git a/apps/api/src/routes/domain/domain.ts b/apps/api/src/routes/domain/domain.ts index 7d9ecb02..a8be57f0 100644 --- a/apps/api/src/routes/domain/domain.ts +++ b/apps/api/src/routes/domain/domain.ts @@ -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'); diff --git a/apps/api/src/services/org/domain.ts b/apps/api/src/services/org/domain.ts index f56c6940..e398d81b 100644 --- a/apps/api/src/services/org/domain.ts +++ b/apps/api/src/services/org/domain.ts @@ -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 { + 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(path: string, init: RequestInit = {}): Promise { + 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; + + 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(); + + 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(`?hostname=${encodeURIComponent(hostname)}`, { + method: 'GET' + }); + + return results.find((result) => result.hostname.toLowerCase() === hostname.toLowerCase()) ?? null; +} + +async function createCustomHostname(hostname: string) { + const body: Record = { + hostname, + ssl: { + method: 'txt', + type: 'dv' + } + }; + + if (env.CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN) { + body.custom_origin_server = env.CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN; + } + + return cloudflareRequest('', { + method: 'POST', + body: JSON.stringify(body) + }); +} + +async function revalidateCustomHostname(customHostname: CloudflareCustomHostname) { + return cloudflareRequest(`/${customHostname.id}`, { + method: 'PATCH', + body: JSON.stringify({ + ssl: { + method: 'txt', + type: 'dv' } - } - ).then((res) => res.json()); -}; + }) + }); +} + +export async function connectDomain(hostname: string): Promise { + const existing = await getCustomHostnameByDomain(hostname); + const customHostname = existing ?? (await createCustomHostname(hostname)); + + return toDomainSetupResult(hostname, customHostname); +} + +export async function refreshDomain(hostname: string): Promise { + 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 { + const existing = await getCustomHostnameByDomain(hostname); + + if (existing) { + await cloudflareRequest(`/${existing.id}`, { + method: 'DELETE' + }); + } + + return { + hostname, + status: 'removed', + verified: false, + reconnectRequired: false, + message: getStatusMessage('removed'), + provider: 'cloudflare', + dnsRecords: [] + }; +} diff --git a/apps/dashboard/src/lib/features/org/api/org.svelte.ts b/apps/dashboard/src/lib/features/org/api/org.svelte.ts index fddf5666..0427a3cc 100644 --- a/apps/dashboard/src/lib/features/org/api/org.svelte.ts +++ b/apps/dashboard/src/lib/features/org/api/org.svelte.ts @@ -353,23 +353,21 @@ class OrgApi extends BaseApiWithErrors { } /** - * Sends a domain request (verify, add, or remove domain) - * @param key Domain operation key: 'verify_domain', 'add_domain', or 'remove_domain' + * Sends a domain request (connect, refresh, or remove) + * @param action Domain operation action * @param domain Domain name * @returns Domain request response data */ - async sendDomainRequest(key: 'verify_domain' | 'add_domain' | 'remove_domain', domain: string) { + async sendDomainRequest(action: 'connect' | 'refresh' | 'remove', domain: string) { return this.execute({ requestFn: () => classroomio.domain.$post({ json: { - params: { - key, - domain - } + action, + domain } }), - logContext: `processing domain request: ${key}`, + logContext: `processing domain request: ${action}`, onSuccess: () => { this.success = true; this.errors = {}; diff --git a/apps/dashboard/src/lib/features/org/utils/types.ts b/apps/dashboard/src/lib/features/org/utils/types.ts index e8bcedda..e73cf3c5 100644 --- a/apps/dashboard/src/lib/features/org/utils/types.ts +++ b/apps/dashboard/src/lib/features/org/utils/types.ts @@ -53,6 +53,8 @@ export type UpdateOrganizationSuccess = Extract; export type DomainRequestSuccess = Extract; +export type DomainRequestData = DomainRequestSuccess['data']; +export type DomainRequestStatus = DomainRequestData['status']; // SSO types export type GetSsoConfigRequest = (typeof classroomio.organization)['sso']['$get']; diff --git a/apps/dashboard/src/lib/features/settings/pages/domains.svelte b/apps/dashboard/src/lib/features/settings/pages/domains.svelte index e3c3c2fd..5b156908 100644 --- a/apps/dashboard/src/lib/features/settings/pages/domains.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/domains.svelte @@ -1,11 +1,12 @@ - Something unexpected occured + {isNotFound ? 'Page not found' : 'Something unexpected occurred'} - -
- - -
-
+{#if isNotFound} + +
+ +
+
+{:else} + +
+ + +
+
+{/if} diff --git a/apps/dashboard/src/routes/course/[slug]/enroll/+page.server.ts b/apps/dashboard/src/routes/course/[slug]/enroll/+page.server.ts index e54e1ba7..14851d29 100644 --- a/apps/dashboard/src/routes/course/[slug]/enroll/+page.server.ts +++ b/apps/dashboard/src/routes/course/[slug]/enroll/+page.server.ts @@ -1,14 +1,14 @@ import { classroomio } from '$lib/utils/services/api'; import { getApiKeyHeaders } from '$lib/utils/services/api/server'; import { calcCourseDiscount } from '$lib/utils/functions/course'; -import { redirect } from '@sveltejs/kit'; +import { error } from '@sveltejs/kit'; export const load = async ({ params, url }) => { const slug = params?.slug ?? ''; const inviteToken = url.searchParams.get('invite_token') ?? null; if (!slug) { - redirect(307, '/404'); + throw error(404, 'Not found'); } if (inviteToken) { @@ -20,7 +20,7 @@ export const load = async ({ params, url }) => { const result = await response.json(); if (!result.success || !result.data) { - redirect(307, '/404'); + throw error(404, 'Not found'); } const data = result.data; @@ -50,9 +50,9 @@ export const load = async ({ params, url }) => { requiresPaymentOrInvite: false, token: inviteToken }; - } catch (error) { - console.error('Enroll load: failed to load invite', error); - redirect(307, '/404'); + } catch (e) { + console.error('Enroll load: failed to load invite', e); + throw error(404, 'Not found'); } } @@ -63,7 +63,7 @@ export const load = async ({ params, url }) => { const result = await response.json(); if (!result.success || !result.data) { - redirect(307, '/404'); + throw error(404, 'Not found'); } const courseData = result.data; @@ -99,8 +99,8 @@ export const load = async ({ params, url }) => { requiresPaymentOrInvite: !isFree, token: null as string | null }; - } catch (error) { - console.error('Enroll load: failed to load course by slug', error); - redirect(307, '/404'); + } catch (e) { + console.error('Enroll load: failed to load course by slug', e); + throw error(404, 'Not found'); } }; diff --git a/apps/dashboard/src/routes/invite/[hash]/+layout.server.ts b/apps/dashboard/src/routes/invite/[hash]/+layout.server.ts index c9871573..e48b3e69 100644 --- a/apps/dashboard/src/routes/invite/[hash]/+layout.server.ts +++ b/apps/dashboard/src/routes/invite/[hash]/+layout.server.ts @@ -1,7 +1,7 @@ import { getOrgBySiteName } from '$features/org/api/org.server'; import { classroomio } from '$lib/utils/services/api'; import { getApiKeyHeaders } from '$lib/utils/services/api/server'; -import { redirect } from '@sveltejs/kit'; +import { error } from '@sveltejs/kit'; export const load = async ({ params = { hash: '' } }) => { try { @@ -28,8 +28,8 @@ export const load = async ({ params = { hash: '' } }) => { invite: result.data, currentOrg }; - } catch (error) { - console.error('Error decoding organization invite params.hash', error); - redirect(307, '/404'); + } catch (e) { + console.error('Error decoding organization invite params.hash', e); + throw error(404, 'Not found'); } }; diff --git a/packages/utils/src/validation/organization/domain.ts b/packages/utils/src/validation/organization/domain.ts new file mode 100644 index 00000000..f3ca63d3 --- /dev/null +++ b/packages/utils/src/validation/organization/domain.ts @@ -0,0 +1,11 @@ +import * as z from 'zod'; + +export const ZDomainAction = z.enum(['connect', 'refresh', 'remove']); + +export const ZDomainActionRequest = z.object({ + action: ZDomainAction, + domain: z.string().min(1) +}); + +export type TDomainAction = z.infer; +export type TDomainActionRequest = z.infer; diff --git a/packages/utils/src/validation/organization/index.ts b/packages/utils/src/validation/organization/index.ts index bddb489d..4224f806 100644 --- a/packages/utils/src/validation/organization/index.ts +++ b/packages/utils/src/validation/organization/index.ts @@ -1,5 +1,6 @@ export * from './audience'; export * from './organization'; +export * from './domain'; export * from './quiz'; export * from './exercise'; export * from './invite'; diff --git a/prd/custom-domain-cloudflare/README.md b/prd/custom-domain-cloudflare/README.md new file mode 100644 index 00000000..4fc75a24 --- /dev/null +++ b/prd/custom-domain-cloudflare/README.md @@ -0,0 +1,300 @@ +# Cloudflare Custom Subdomain Reconnect PRD + +## Status +- Draft + +## Date +- March 22, 2026 + +## Purpose +Replace the current Vercel-managed custom domain setup flow with a Cloudflare-managed custom subdomain flow, and require existing customers to reconnect their custom domains through the new setup path. + +## Problem Statement +Custom-domain setup is currently tightly coupled to Vercel project-domain APIs. The backend route in `apps/api/src/routes/domain/domain.ts` proxies directly to Vercel-specific actions, and the dashboard UI in `apps/dashboard/src/lib/features/settings/pages/domains.svelte` hardcodes Vercel DNS guidance (`cname.vercel-dns.com`). + +This creates three problems: +1. Domain provisioning logic is provider-specific instead of product-specific. +2. The dashboard cannot render provider-agnostic DNS instructions or richer setup states. +3. Existing setup assumes Vercel remains the control plane for custom domains, which no longer matches the intended infrastructure direction. + +We are not doing an in-place live migration of existing Vercel-managed domains. Instead, organizations will be asked to set up their custom domain again through a new Cloudflare-backed flow. + +## Confirmed Decisions +1. Cloudflare will manage custom-domain setup going forward. +2. The app itself will not be served by Cloudflare as a full-app hosting migration in this project. +3. Scope is limited to custom subdomains only. +4. Apex/root domains are out of scope. +5. Existing customers will reconnect their domain through the new flow instead of being transparently migrated from Vercel. + +## Goals +1. Replace Vercel-specific custom-domain setup with a Cloudflare-backed setup flow. +2. Keep the existing organization data model centered on `customDomain` and `isCustomDomainVerified`. +3. Move domain orchestration into the backend so the dashboard does not manage provider sequencing itself. +4. Render DNS instructions from API responses instead of hardcoded provider copy. +5. Preserve runtime host-to-organization lookup for verified custom domains. +6. Provide a clear reset and reconnect path for organizations with previously configured domains. + +## Non-Goals +- Supporting apex/root domains. +- Running the main app on Cloudflare. +- Preserving old Vercel domain attachments indefinitely. +- Building a multi-provider long-term abstraction unless required for rollout safety. +- Automatic migration of existing Vercel domain config without user action. + +## Data Sources Checked +- `apps/api/src/routes/domain/domain.ts` +- `apps/api/src/services/org/domain.ts` +- `apps/dashboard/src/lib/features/settings/pages/domains.svelte` +- `apps/dashboard/src/lib/features/org/api/org.svelte.ts` +- `apps/dashboard/src/lib/utils/functions/domain.ts` +- `apps/dashboard/src/lib/utils/services/org/domain.ts` +- `apps/dashboard/src/lib/utils/types/org.ts` +- `apps/dashboard/src/lib/features/app/layout-setup.ts` +- `apps/dashboard/src/lib/features/org/api/org.server.ts` +- `apps/docs/content/docs/how-to-guides/customize-organization.mdx` + +## Current State + +### Backend +- `POST /domain` accepts `verify_domain`, `add_domain`, and `remove_domain`. +- Those actions proxy directly to Vercel in `apps/api/src/services/org/domain.ts`. +- The backend contract is provider-shaped rather than product-shaped. + +### Dashboard +- The settings page stores `customDomain` first via organization update, then separately calls the domain route. +- DNS instructions are hardcoded to `CNAME -> cname.vercel-dns.com`. +- Verification UX is reduced to a simple pending/verified state with no provider-neutral detail. + +### Runtime +- Runtime org resolution already supports `customDomain` lookups in `apps/dashboard/src/lib/features/app/layout-setup.ts`. +- Server-side org fetchers already support `customDomain + isCustomDomainVerified`. +- This means the main runtime routing model can stay mostly intact if Cloudflare forwards the original host correctly. + +### Docs +- User-facing docs still say external custom domains are “coming soon”, which no longer matches the in-app paid feature. + +## User Story +As an organization admin, I want to connect a branded subdomain like `courses.example.com` to ClassroomIO through a clear Cloudflare-backed flow, so learners can access my organization site on my own domain. + +As an existing organization admin with a previously configured domain, I want to understand that I must reconnect the domain through the new setup flow, and I want the product to show me the exact DNS record I need to add. + +## Functional Requirements +1. Custom domain setup must accept only valid subdomains. +2. The API must return normalized domain setup data instead of Vercel-specific payloads. +3. The backend must own the full provisioning sequence: +- validate input +- provision/register domain with Cloudflare +- persist organization domain state +- return DNS instructions and current status +4. The dashboard must render DNS instructions from API data. +5. The dashboard must support at least these states: +- no domain configured +- reconnect required +- pending DNS +- pending verification +- verified +- error +6. The dashboard must allow admins to remove a configured domain. +7. Existing domains that were previously configured through Vercel must be markable as needing reconnection. +8. Host-based org lookup must continue to work for verified custom domains. +9. Free-plan gating for custom domains must remain unchanged unless a separate pricing decision changes it. + +## Non-Functional Requirements +1. Domain-related errors must be actionable and provider-neutral in UI copy. +2. Domain setup operations must be idempotent enough for safe retries by admins. +3. Partial state must be minimized; backend orchestration should avoid “saved in DB but not provisioned” behavior. +4. Rollout must allow safe validation in staging before production reset/reconnect begins. + +## Product Requirements + +### 1) Setup Scope +- Accept only subdomains such as `courses.example.com` or `www.example.com`. +- Reject apex domains such as `example.com`. +- Continue rejecting `*.classroomio.com` as a custom domain. + +### 2) Reconnect Requirement +- Existing organizations with `customDomain` set under the old system must not be treated as automatically healthy. +- The product should clearly indicate that custom domains must be reconnected through the new Cloudflare flow. +- A one-time reset/backfill step may mark existing domains as unverified or reconnect-required. + +### 3) Backend-Owned Orchestration +The system should move from this sequence: +1. dashboard updates organization record +2. dashboard calls domain provider API + +To this sequence: +1. dashboard submits desired domain +2. backend validates and provisions the domain +3. backend updates organization state +4. backend returns DNS instructions and status + +### 4) DNS Instruction Model +The API should return a product-owned response shape, for example: +- `status` +- `verified` +- `hostname` +- `dnsRecords[]` +- `message` +- `reconnectRequired` + +The dashboard must not hardcode provider DNS values. + +### 5) Verification Model +The system should support a refresh/check action that re-queries current provider state and updates organization verification status. + +Minimum statuses: +- `pending_dns` +- `pending_verification` +- `verified` +- `error` + +### 6) Remove Domain +Removing a domain should: +1. remove or deactivate provider-side setup where applicable +2. clear organization `customDomain` +3. clear verification state + +## UX Requirements +1. The settings page must clearly tell admins that only subdomains are supported. +2. Existing domains that need reconnection must show a clear callout instead of silently appearing valid. +3. DNS records must be copyable from the UI. +4. Status badges and refresh actions must remain available after setup begins. +5. User-facing copy must move through dashboard translations and not be hardcoded in component markup. + +## Technical Approach + +### Recommended Direction +Use a Cloudflare-backed service layer for custom-domain operations and expose a provider-neutral API contract to the dashboard. + +### Backend Changes +1. Replace Vercel-specific service functions in `apps/api/src/services/org/domain.ts`. +2. Refactor `apps/api/src/routes/domain/domain.ts` so route responses are normalized around ClassroomIO product states, not Vercel response shapes. +3. Move organization-state persistence into backend orchestration rather than split frontend/backend sequencing. +4. Add any required Cloudflare env configuration in API config. + +### Dashboard Changes +1. Update `apps/dashboard/src/lib/features/settings/pages/domains.svelte` to consume normalized API responses. +2. Remove hardcoded Vercel DNS values. +3. Replace any Vercel-specific types in `apps/dashboard/src/lib/utils/types/org.ts`. +4. Remove or refactor Vercel-specific helpers in `apps/dashboard/src/lib/utils/services/org/domain.ts`. +5. Keep request typing in feature `utils/types.ts` consistent with existing dashboard conventions. + +### Runtime Changes +1. Keep `apps/dashboard/src/lib/features/app/layout-setup.ts` as the primary host-to-org resolver. +2. Verify that custom domains routed through the new provider path still preserve host-based organization lookup. +3. No large routing-model rewrite is expected in this project. + +## Current State Audit + +| Area | Current State | Gap | Priority | +| --- | --- | --- | --- | +| API domain setup | Vercel-specific proxy route | Needs Cloudflare-backed product contract | P0 | +| Backend orchestration | Frontend updates org, then provisions domain | Partial-state risk | P0 | +| Dashboard DNS instructions | Hardcoded Vercel CNAME target | Must become API-driven | P0 | +| Verification states | Basic pending/verified only | Needs richer reconnect/pending/error model | P0 | +| Existing domain rollout | Assumes current domain remains valid | Need reset + reconnect plan | P0 | +| Runtime org lookup | Already supports verified custom domains | Needs validation only, not redesign | P1 | +| Docs | External custom domains marked “coming soon” | Needs correction | P1 | + +## Implementation Plan + +### Phase 1: API Contract + Cloudflare Service +1. Replace Vercel-specific service implementation in `apps/api/src/services/org/domain.ts`. +2. Redesign `POST /domain` response shape to be provider-neutral. +3. Implement backend orchestration so setup and persistence happen together. +4. Add backend validation to enforce subdomain-only custom domains. + +### Phase 2: Dashboard Reconnect UX +1. Update the domains settings page to render DNS instructions from API responses. +2. Add reconnect-required state for existing domains. +3. Update remove and refresh flows to use the new backend contract. +4. Remove Vercel-specific helper/types that no longer apply. + +### Phase 3: Existing Domain Reset +1. Identify organizations with existing `customDomain` values. +2. Decide reset behavior: +- mark all as unverified +- add reconnect-required marker in UI +- optionally notify admins out-of-band +3. Validate that old Vercel-managed domains no longer appear healthy in the product by default. + +### Phase 4: Docs + Rollout +1. Update docs to describe the Cloudflare-backed custom-domain flow. +2. Align in-app copy with product behavior. +3. Test in staging with at least one reconnect scenario and one fresh setup scenario. +4. Roll out production reset/reconnect. + +## Required File Touchpoints + +### Core API +- `apps/api/src/routes/domain/domain.ts` +- `apps/api/src/services/org/domain.ts` +- `apps/api/src/config/env.ts` or related provider config files +- `apps/api/src/services/organization.ts` if orchestration is moved into org service layer + +### Dashboard +- `apps/dashboard/src/lib/features/settings/pages/domains.svelte` +- `apps/dashboard/src/lib/features/org/api/org.svelte.ts` +- `apps/dashboard/src/lib/features/org/utils/types.ts` +- `apps/dashboard/src/lib/utils/functions/domain.ts` +- `apps/dashboard/src/lib/utils/services/org/domain.ts` +- `apps/dashboard/src/lib/utils/types/org.ts` +- `apps/dashboard/src/lib/utils/translations/en.json` + +### Runtime Validation +- `apps/dashboard/src/lib/features/app/layout-setup.ts` +- `apps/dashboard/src/lib/features/org/api/org.server.ts` + +### Docs +- `apps/docs/content/docs/how-to-guides/customize-organization.mdx` + +## Rollout Plan +1. Build the Cloudflare-backed flow behind a feature flag if needed. +2. Validate fresh setup and reconnect flows in staging. +3. Reset existing production custom domains into reconnect-required state. +4. Publish updated docs and product copy. +5. Ask affected organizations to reconnect their domains. +6. Remove stale Vercel-specific setup code after production rollout stabilizes. + +## Testing Strategy + +### Unit Tests +1. Domain input normalization and subdomain validation. +2. Backend mapping from provider responses to product statuses. +3. Reconnect-required state handling. + +### Integration Tests +1. Add domain returns DNS instructions and a pending status. +2. Refresh/verify updates `isCustomDomainVerified` when provider verification succeeds. +3. Remove domain clears organization state correctly. +4. Verified custom domain still resolves organization context via host-based lookup. + +### Manual Verification +1. Fresh org connects a new subdomain successfully. +2. Existing org with previously configured domain is shown reconnect-required state. +3. DNS instructions shown in UI match backend response. +4. Verified domain loads the correct organization site. +5. Removing the domain clears status and UI state cleanly. + +## Acceptance Criteria +1. The system no longer depends on Vercel project-domain APIs for custom-domain setup. +2. Only subdomains are accepted for custom domains. +3. The dashboard no longer hardcodes `cname.vercel-dns.com`. +4. Existing domains can be explicitly reset into a reconnect-required flow. +5. Verified custom domains continue to resolve to the correct organization site. +6. Docs and in-app copy accurately describe the supported custom-domain flow. + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| Backend/provider mismatch causes false verification state | Broken domain UX | Normalize provider states and add integration coverage | +| Existing domains appear valid after rollout even though reconnect is required | Admin confusion and broken trust | Reset state explicitly and surface reconnect-required UI | +| Host forwarding differs from current assumptions | Verified domains fail to resolve org context | Validate host preservation in staging before rollout | +| Frontend/backend sequencing remains split | Partial failures persist | Move orchestration into backend before release | +| Docs lag behind rollout | Support burden | Update docs in same release | + +## Open Questions +1. Do we need a new persisted field for reconnect-required, or is resetting `isCustomDomainVerified=false` with derived UI state sufficient? +2. Should production rollout notify affected organizations by email, in-app banner, or both? +3. Do we want to keep the existing `/domain` endpoint shape and only change payloads, or introduce a clearer route contract now?