Skip to content

Custom Domains — verified hostnames per site

Granit.Cms.Hostnames lets a site own real domains — clinic.acme.com instead of a path on a shared host. It is a thin CMS layer over the framework’s Granit.Hostnames module: the hostname aggregate, verification, and certificate lifecycle live in the framework; the CMS layer claims hostnames for sites and resolves an incoming host back to a SiteId.

A site registers a hostname; the framework returns the DNS records the owner must publish and moves the hostname through a verification workflow.

stateDiagram-v2
    [*] --> Pending: register
    Pending --> Verifying: begin verification (DNS challenge)
    Verifying --> Active: DNS verified
    Verifying --> Error: verification failed
    Error --> Verifying: recheck
    Active --> Verifying: recheck

The ManagedHostname carries the workflow Status (Pending/Verifying/Active/Error), the expected DNS records (challenge TXT + routing records), detected DNS conflicts, and a separate CertificateStatus (Unprovisioned/Provisioning/Secured/Error) with expiry. Failed checks back off on an exponential schedule (1m → 5m → 15m → 1h → 6h → 24h) and go dormant after 20 consecutive failures.

At request time, ISiteHostnameResolver turns the incoming Host header into a SiteId for the site-resolution middleware — the rest of the CMS then runs site-scoped. Resolution is cached when a FusionCache is available.

public interface ISiteHostnameResolver
{
Task<Guid?> ResolveSiteIdAsync(string host, CancellationToken cancellationToken = default);
}

There is no CMS-specific EF Core package here — persistence is owned by Granit.Hostnames.EntityFrameworkCore. Wire both Granit.Hostnames and its EF Core module in the host; the CMS hostnames module only adds the site glue and the lifecycle event handlers.

MapGranitCmsSiteHostnames() maps under /api/cms/sites/{siteId}/hostnames — every operation is site-scoped and gated by the site permissions (no dedicated hostname permission):

RoutePermissionPurpose
GET /Cms.Sites.ReadList the site’s hostnames (status, DNS records, cert state)
GET /availability?host=…Cms.Sites.ReadIs a hostname free? (global uniqueness pre-check)
POST /Cms.Sites.ManageClaim a hostname (409 if already taken)
DELETE /{hostnameId}Cms.Sites.ManageRelease it (404 if not owned by this site)
POST /{hostnameId}/verify-nowCms.Sites.ManageTrigger a manual DNS recheck (202)