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.
This commit is contained in:
Rotimi Best
2026-06-14 10:09:40 +01:00
parent ee4d4922d6
commit c97284f870
5 changed files with 23 additions and 18 deletions
+1
View File
@@ -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"
},
+7 -3
View File
@@ -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<T>(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'
@@ -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}
<Field.Field>
<Field.Label class="font-bold">{$t('components.settings.domains.your_domain')}</Field.Label>
<DomainInput bind:value={customDomain} placeholder="courses.yourwebsite" disabled={$isFreePlan} />
<DomainInput bind:value={customDomain} placeholder="yourwebsite.com" suffix="" disabled={$isFreePlan} />
{#if errors.customDomain}
<Field.Error>{errors.customDomain}</Field.Error>
{/if}
@@ -24,11 +24,15 @@
</script>
<InputGroup.Root class={cn('ui:w-full', className)} data-slot="domain-input">
<InputGroup.Addon>
<InputGroup.Text>{prefix}</InputGroup.Text>
</InputGroup.Addon>
{#if prefix}
<InputGroup.Addon>
<InputGroup.Text>{prefix}</InputGroup.Text>
</InputGroup.Addon>
{/if}
<InputGroup.Input {...restProps} {placeholder} bind:value {disabled} class="!ps-0.5" />
<InputGroup.Addon align="inline-end">
<InputGroup.Text>{suffix}</InputGroup.Text>
</InputGroup.Addon>
{#if suffix}
<InputGroup.Addon align="inline-end">
<InputGroup.Text>{suffix}</InputGroup.Text>
</InputGroup.Addon>
{/if}
</InputGroup.Root>
+3
View File
@@ -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