From c97284f870ce1e814e8a283859112d3d140ce932 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Sun, 14 Jun 2026 10:09:40 +0100 Subject: [PATCH] feat(domains): support apex domains and non-.com TLDs Loosen the custom-domain regex to accept apex domains (1+ labels) in addition to subdomains; the TLD already allowed any letters. Suppress the CNAME record for apex hostnames since a CNAME is invalid at the zone apex, and surface only the A record there. Drop the frontend apex rejection and the misleading ".com" input suffix, and render the domain-input prefix/suffix addons only when set. --- apps/api/package.json | 1 + apps/api/src/services/org/domain.ts | 10 +++++++--- .../lib/features/settings/pages/domains.svelte | 11 ++--------- .../src/custom/domain-input/domain-input.svelte | 16 ++++++++++------ pnpm-lock.yaml | 3 +++ 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 5247b4d8..0c806a8d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -67,6 +67,7 @@ "katex": "^0.16.10", "marked": "^7.0.3", "nanoid": "^5.1.5", + "tldts": "^6.1.86", "zod": "^4.1.8", "zod-openapi": "^5.4.1" }, diff --git a/apps/api/src/services/org/domain.ts b/apps/api/src/services/org/domain.ts index 51018bf4..526b15e9 100644 --- a/apps/api/src/services/org/domain.ts +++ b/apps/api/src/services/org/domain.ts @@ -1,6 +1,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors'; import { env } from '@cio/core/config/env'; +import { parse } from 'tldts'; export type DomainSetupStatus = | 'reconnect_required' @@ -40,7 +41,7 @@ interface ApproximatedVhost { ssl_active_until?: string | null; } -const SUPPORTED_CUSTOM_DOMAIN_PATTERN = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){2,}[a-z]{2,63}$/i; +const SUPPORTED_CUSTOM_DOMAIN_PATTERN = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i; const APPROXIMATED_BASE_URL = 'https://cloud.approximated.app/api'; @@ -110,6 +111,7 @@ async function approximatedRequest(path: string, init: RequestInit = {}): Pro function buildDnsRecords(hostname: string, vhost?: ApproximatedVhost | null): DomainDnsRecord[] { const targetIp = env.APPROXIMATED_DNS_TARGET_IP; const targetCname = env.APPROXIMATED_DNS_TARGET_CNAME; + const isApex = !parse(hostname).subdomain; const records: DomainDnsRecord[] = []; const isResolvingToTarget = Boolean( @@ -120,7 +122,9 @@ function buildDnsRecords(hostname: string, vhost?: ApproximatedVhost | null): Do if (targetIp) { records.push({ type: 'A', name: hostname, value: targetIp, status }); } - if (targetCname) { + + // A CNAME is invalid at the zone apex, so only offer it for subdomains. + if (targetCname && !isApex) { records.push({ type: 'CNAME', name: hostname, value: targetCname, status }); } @@ -190,7 +194,7 @@ export function normalizeCustomDomain(domain: string) { 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.', + 'Enter a valid domain like yourwebsite.com or courses.yourwebsite.com.', ErrorCodes.VALIDATION_ERROR, 400, 'domain' diff --git a/apps/dashboard/src/lib/features/settings/pages/domains.svelte b/apps/dashboard/src/lib/features/settings/pages/domains.svelte index 83914f33..abac933a 100644 --- a/apps/dashboard/src/lib/features/settings/pages/domains.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/domains.svelte @@ -60,7 +60,7 @@ if (!domainApex) return recordName; const suffix = '.' + domainApex; - if (recordName === domainApex) return recordName; + if (recordName === domainApex) return '@'; if (recordName.endsWith(suffix)) return recordName.slice(0, -suffix.length); return recordName; @@ -190,13 +190,6 @@ } const sanitizedDomain = sanitizeDomain(customDomain); - const { parse } = await import('tldts/dist/es6/index.js'); - const details = parse(sanitizedDomain); - - if (!details.subdomain) { - errors.customDomain = $t('components.settings.domains.custom_domain_error'); - return; - } if (sanitizedDomain.includes(BRAND_ROOT_DOMAIN) || sanitizedDomain.includes(TENANT_ROOT_DOMAIN)) { errors.customDomain = $t('components.settings.domains.custom_domain_not_classroomio'); @@ -549,7 +542,7 @@ {:else} {$t('components.settings.domains.your_domain')} - + {#if errors.customDomain} {errors.customDomain} {/if} diff --git a/packages/ui/src/custom/domain-input/domain-input.svelte b/packages/ui/src/custom/domain-input/domain-input.svelte index f932fe9a..51d3c4b8 100644 --- a/packages/ui/src/custom/domain-input/domain-input.svelte +++ b/packages/ui/src/custom/domain-input/domain-input.svelte @@ -24,11 +24,15 @@ - - {prefix} - + {#if prefix} + + {prefix} + + {/if} - - {suffix} - + {#if suffix} + + {suffix} + + {/if} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cd92a99..3be54ffa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: nanoid: specifier: ^5.1.5 version: 5.1.6 + tldts: + specifier: ^6.1.86 + version: 6.1.86 zod: specifier: 4.2.1 version: 4.2.1