Custom Domains

Bring-your-own hostnames, DNS verification & SSL

The domains block lets your tenants attach their own hostnames — an apex like band.com, its www.band.com, or a subdomain such as shop.band.com — on top of the platform subdomain they already get. Each hostname is proved-owned via live DNS, provisioned with TLS, and then resolves straight to the owning tenant.

Ownership is verified against real DNS (node:dns TXT/CNAME lookups), certificates come either from your platform ingress (manual DNS) or Cloudflare for SaaS, and an optional Domain Concierge flow can register brand-new domains on a tenant's behalf. Everything sits on multi-tenant mode and lives in the control-plane schema.

Custom hostnames#

A tenant on your platform already reaches their space at a platform subdomain (acme.platform.com). Custom domains add vanity hostnames the tenant owns — apex, www and arbitrary subdomains — that route to the same tenant once ownership is proven. Every hostname is classified so the framework knows how to verify and route it.

apexband.comOptional

A root domain (two labels, no leading www). Ownership is proven with a TXT record; routing points an A record at your ingress and, because apex A-records can't be reliably auto-probed, usually needs the explicit activate step after verification.

wwwwww.band.comOptional

The conventional www host. Gated by allowWwwDomains. Verified by TXT and routed by CNAME to your cnameTarget.

subdomainshop.band.comOptional

Any other sub-host the tenant controls. Verified by TXT and routed by CNAME, so it can auto-activate once the CNAME resolves to the target.

platform_subdomainacme.platform.comOptional

A host under your own platformBaseDomain. Detected as first-party (not a customer domain) so it is never treated as an apex the tenant must verify.

config.domains#

One block turns the whole capability on. Choose a provider, tell the framework your platform base domain and the DNS target customer hostnames should point at, then decide which hostname shapes and flows are allowed. cnameTarget and every cloudflare.* field are env-name-or-literal: if an env var of that name is set it wins, otherwise the literal value is used.

config.nucleus.json — domains
1{2  "database": { "url": "DATABASE_URL", "isMultiTenant": true },3  "domains": {4    "enabled": true,5    "basePath": "/domains",6    "provider": "cloudflare_for_saas",7    "platformBaseDomain": "platform.com",8    "cnameTarget": "customers.platform.com",9    "allowApexDomains": true,10    "allowWwwDomains": true,11    "allowManagedRegistrations": false,12    "reservedHostnames": ["www.platform.com", "api.platform.com"],13    "verification": {14      "defaultMethod": "txt",15      "txtRecordPrefix": "_nucleus-challenge",16      "challengeTtlSeconds": 60480017    },18    "cloudflare": {19      "accountId": "CF_ACCOUNT_ID",20      "zoneId": "CF_ZONE_ID",21      "apiToken": "CF_API_TOKEN",22      "fallbackOrigin": "origin.platform.com",23      "cnameTarget": "customers.platform.com"24    },25    "registrar": {26      "provider": "cloudflare_registrar",27      "defaultRegistrantOwnerType": "customer"28    }29  }30}
enabledbooleanOptional

Master switch. When false nothing registers — no routes, no tables, no tenant-resolution indexing.

Defaultfalse
basePathstringOptional

Route prefix the domain endpoints mount under (e.g. /domains/hostnames, /domains/resolve).

Default"/domains"
provider"manual_dns" | "cloudflare_for_saas"Optional

How hostnames are verified and given TLS. manual_dns needs no third-party API; cloudflare_for_saas issues managed certificates. If cloudflare_for_saas is selected but the cloudflare block is incomplete, the framework logs a warning and falls back to manual DNS rather than crashing.

  • manual_dnsNo external API. Issues DNS instructions and verifies ownership against live DNS; TLS is assumed terminated by your ingress.
  • cloudflare_for_saasCreates Cloudflare custom hostnames and DV certificates, tracking activation and cert issuance as two independent lifecycles.
Default"manual_dns"
platformBaseDomainstringOptional

Your own base domain, e.g. "platform.com". Enables exact platform-subdomain detection and auto-reserves the apex so no tenant can claim it.

cnameTargetstringOptional

Env-name-or-literal. The CNAME/A target customer hostnames point at (your ingress). Manual DNS uses this to build routing instructions and to probe live CNAMEs for auto-activation. If left unset, the resolved target falls back to cloudflare.cnameTarget.

Example: "customers.platform.com"

allowApexDomainsbooleanOptional

Whether tenants may attach apex (root) domains. When false, an apex-kind hostname is rejected at create time.

Defaulttrue
allowWwwDomainsbooleanOptional

Whether tenants may attach www hostnames. When false, a www-kind hostname is rejected at create time.

Defaulttrue
allowManagedRegistrationsbooleanOptional

Enables the Domain Concierge flow — requesting brand-new domain registrations through the API. Off by default; registration requests throw when disabled.

Defaultfalse
reservedHostnamesstring[]Optional

Hostnames tenants may never claim (normalized on load). createHostname rejects any match, alongside the platformBaseDomain itself.

Example: ["www.platform.com", "api.platform.com"]

verificationobjectOptional

Ownership-challenge tuning.

defaultMethod"txt" | "cname" | "http"Optional

Informational only — resolved but not consumed by the providers. Manual DNS always issues a TXT challenge; Cloudflare for SaaS uses TXT DV. Setting cname/http here does not change behavior.

Default"txt"
txtRecordPrefixstringOptional

Sets the NAME prefix of the manual-DNS TXT challenge record: <txtRecordPrefix>.<hostname>. It does not affect the record value.

Default"_nucleus-challenge"
challengeTtlSecondsnumberOptional

How long an ownership challenge is valid before it expires. 604800 = 7 days.

Default604800
cloudflareobjectOptional

Cloudflare for SaaS credentials. Each field is env-name-or-literal. The block resolves to null (and the provider falls back to manual DNS) unless accountId, zoneId and apiToken all resolve to non-empty values.

accountIdstringRequired

Cloudflare account id (env name or literal).

zoneIdstringRequired

Zone the custom hostnames are created under (env name or literal).

apiTokenstringRequired

API token with custom-hostname scope, sent as a Bearer token (env name or literal).

fallbackOriginstringOptional

Optional Cloudflare for SaaS fallback origin. Informational only — resolved into config but currently NOT sent to Cloudflare (createCustomHostname posts just { hostname, ssl }), so setting it has no runtime effect (inert), like verification.defaultMethod.

cnameTargetstringOptional

For the Cloudflare provider, cloudflare.cnameTarget takes precedence over the top-level cnameTarget. It also serves as a fallback for the resolved top-level target when the top-level cnameTarget is unset, in which case Manual DNS uses it too.

registrarobjectOptional

Managed-registration (Domain Concierge) settings.

provider"cloudflare_registrar"Optional

Registrar backend. Currently the only value; stamped onto new registration records.

Default"cloudflare_registrar"
defaultRegistrantOwnerType"customer" | "platform"Optional

Default legal registrant when a request does not specify one. platform (the platform is the registrant / renewal payer) is godmin-only at the route.

Default"customer"

DNS ownership verification#

Before a hostname can carry traffic, the tenant must prove they control it. The framework hands back the exact DNS records to add and then checks them against live DNS with node:dns — no polling service or webhook needed. Call verify (or refresh-status) to re-run the check and recompute readiness.

TXT challengeresolveTxtOptional

Manual DNS always issues a TXT record. Name is <txtRecordPrefix>.<normalizedHostname>, value is a fixed nucleus-domain-verification=<48-hex-char token>. A live resolveTxt lookup that finds the token flips verification_status to verified.

CNAME routing proberesolveCnameOptional

For non-apex hostnames with a cnameTarget set, a live resolveCname probe checks the host points at your target. A match sets routing_status active; otherwise it stays pending until the record propagates.

apex routingactivateOptional

Apex domains route via an A record, which cannot be auto-probed. After TXT verification succeeds, an admin activate call sets routing active — this is the expected extra step for apex hostnames on manual DNS.

challenge lifecyclestatusOptional

Verification status can be pending, verified or failed; 'expired' is a defined status but the framework never auto-transitions to it. Each challenge row stores an expiry timestamp (derived from challengeTtlSeconds), yet nothing enforces it — loadPendingChallenge selects the still-pending challenge with no expiry filter, so re-running verify simply re-checks live DNS. A freshly created hostname starts at 'pending_verification' (its status before any verify call); it only moves to 'verifying' after a verify / refresh-status call whose result isn't yet ready and hasn't failed. Failed verification moves the hostname to failed; a not-yet-ready-but-not-failed hostname sits in verifying. Instructions are idempotent — getInstructions rebuilds the records from dns_target plus the hostname's challenge rows (queried by hostname id, regardless of status) any time you need to re-show the steps.

SSL provisioning#

Certificates are handled by whichever provider you chose. Manual DNS leans on your platform ingress to terminate TLS, while Cloudflare for SaaS issues and tracks a managed DV certificate per hostname.

manual_dnsnot_requiredOptional

TLS is assumed terminated at your platform ingress, so certificate_status is not_required from the start. The framework only proves ownership and points routing at cnameTarget; you bring the wildcard/edge certificate.

cloudflare_for_saasdv certificateOptional

Creates a Cloudflare custom hostname with an SSL DV certificate (min TLS 1.2) and tracks two independent lifecycles: hostname activation (result.status) and certificate issuance (result.ssl.status). Blocked/moved/deleted marks routing failed; a validation-timed-out, deactivating OR deleted SSL status marks the certificate failed, and ssl validation errors (validation_errors > 0) mark verification failed.

validation recordscloudflare-ownedOptional

Cloudflare for SaaS uses its own ownership_verification and ssl.validation_records — not the _nucleus-challenge TXT — so the DNS records you surface to the tenant come from Cloudflare, not the manual TXT challenge.

Full-hostname tenant resolution#

Once a hostname is active it resolves straight to its tenant. The tenant registry matches the full normalized hostname first — ahead of subdomain, header and main-schema fallback — so band.com, www.band.com and shop.band.com all land on the right tenant regardless of your base tenantResolution strategy.

startup indexloadDomainHostnamesOptional

At boot the registry indexes every domain_hostnames row where status is active and whose tenant_id maps to a known tenant. It is a safe no-op if the table is absent, and a hostname with no matching tenant is simply skipped.

live updatesregister / unregisterOptional

The index stays live: activate / verify-success calls registerDomainHostname and disable calls unregisterDomainHostname, so newly-verified hostnames start resolving immediately and disabled ones stop.

GET /domains/resolvepublicOptional

Given a hostname (or the request host header) returns { hostnameId, tenantId, schemaName, isPrimary, redirectToHostname } for the active match — the primitive an edge/proxy uses to route a raw hostname to a tenant. 400 if no hostname, 404 if no active match.

Managed registration (Domain Concierge)#

Beyond attaching hostnames tenants already own, the optional Domain Concierge flow requests registration of brand-new domains. It is gated by allowManagedRegistrations. The service owns the lifecycle and audit trail only — it never fabricates availability or pricing; the actual registrar binding is driven by your deployment calling transition().

requestPOST /domains/registrationsOptional

Body { requestedDomain, ownerId, ownerType?, tenantId?, registrantOwnerType?, termsVersion (required), termsAccepted (required true), autoRenew? }. ownerType is one of 'tenant' | 'organization' | 'user' | 'external' and defaults to 'tenant' when omitted. termsAccepted must be truthy or the request throws.

lifecycleawaiting_payment → activeOptional

New records start at awaiting_payment (not draft), stamped with the registrar provider and registrantOwnerType. This shows the common path; transition() accepts any RegistrationStatus, so records may move through availability_checked, registering, active, failed, transfer_requested, transferred_out, cancelled and expired.

transition()deployment-drivenOptional

Your deployment advances a registration by calling transition(id, status, patch?) with { registeredDomain, providerRegistrationId, priceAmount, currency, renewalDate, failureReason }. Each transition emits a registration_<status> event. cancel sets cancelled; requestTransferOut sets transfer_requested.

Lifecycle routes#

All endpoints mount under basePath (default /domains) and return a { success, message, data } envelope. Auth uses the x-user-id header (401 without it); a 503 means the service isn't ready. Only GET /domains/resolve is public. As of v0.9.746 all five domain tables (domain_hostnames, domain_registrations, domain_provider_records, domain_verification_challenges, domain_events) are classified sensitive system tables, so generic-entity-CRUD WRITES to them require godmin (a non-godmin write returns 403) — tenant callers MUST go through these lifecycle routes, which derive tenant/ownership server-side.

GET /domains/resolve?hostname=public

Full-hostname → tenant lookup. Falls back to the host header; 400 if none, 404 if no active match.

POST /domains/hostnamesx-user-id

Create a hostname ({ hostname, ownerId, ownerType?, tenantId?, schemaName?, isPrimary? }) → { hostname, instructions }. ownerType is one of 'tenant' | 'organization' | 'user' | 'external' and defaults to 'tenant' when omitted. 400 on an invalid, reserved or duplicate hostname. Non-godmin callers have tenantId and schemaName forced to their own verified tenant.

GET /domains/hostnamesx-user-id

List hostnames. Non-godmin callers are pinned to their own tenant (client tenantId/ownerId filters ignored); godmin may filter by tenantId, ownerId, or list all.

GET /domains/hostnames/:idx-user-id

Fetch one. 404 if missing or cross-tenant (existence not confirmed for other tenants).

GET /domains/hostnames/:id/instructionsx-user-id

Rebuild the DNS records to apply (A/CNAME target plus challenge rows). Idempotent; 404 if cross-tenant.

POST /domains/hostnames/:id/verifyx-user-id

Run ownership + provider status re-sync and recompute readiness. 404 if cross-tenant.

POST /domains/hostnames/:id/activatex-user-id

Admin force-activate. Requires verification_status='verified' (else 400); the expected step for apex hostnames on manual DNS. 404 if cross-tenant.

POST /domains/hostnames/:id/disablex-user-id

Delete the provider hostname, set status disabled and remove it from the resolution index. 404 if cross-tenant.

POST /domains/hostnames/:id/make-primaryx-user-id

Mark this hostname primary, clearing the flag on others in the same tenant/owner scope. 404 if cross-tenant.

POST /domains/hostnames/:id/refresh-statusx-user-id

Alias of verify. 404 if cross-tenant.

POST /domains/registrationsx-user-id

Request a managed registration (Domain Concierge). Non-godmin tenantId is bound to the caller and registrantOwnerType forced to 'customer'.

GET /domains/registrationsx-user-id

List registrations. Non-godmin callers are pinned to their own tenant (filters ignored).

GET /domains/registrations/:idx-user-id

Fetch one registration. 404 if missing or cross-tenant.

POST /domains/registrations/:id/cancelx-user-id

Move the registration to status cancelled. 404 if cross-tenant.

POST /domains/registrations/:id/transfer-outx-user-id

Move the registration to status transfer_requested. 404 if cross-tenant.

Tenant binding & ownership (v0.9.744+)#

Because a live custom hostname routes traffic to a tenant's schema, who a hostname is bound to is a security decision, not a convenience field. As of v0.9.744 a non-godmin caller can only ever bind, see and act on hostnames within their own verified tenant. Godmin is exempt and may provision on behalf of any tenant.

server-derived binding on createtenantId + schemaNameOptional

On POST /domains/hostnames a non-godmin caller has BOTH tenantId and schemaName derived server-side from the request's verified x-tenant-schema (resolveTenantIdForSchema). A body schemaName naming a different tenant → 403 "Cannot bind a hostname to another tenant's schema"; a body tenantId naming a different tenant → 403 "Cannot bind a hostname to another tenant". Guarding schemaName alone was insufficient because tenantId is the actual routing key.

tenant-scoped reads & lifecycle404, not 403Optional

List ignores client tenantId/ownerId filters and pins to the caller's tenant. GET :id, instructions, and every lifecycle op (verify / activate / disable / make-primary / refresh-status) load and ownership-check the record before mutating, returning 404 "Hostname not found" on a cross-tenant id so existence is never confirmed.

registrations are tenant-scopedplatform = godmin-onlyOptional

POST /domains/registrations binds tenantId to the caller's verified tenant (foreign → 403 "Cannot register a domain for another tenant") and forces registrantOwnerType:'customer'. Requesting 'platform' (billing the platform as renewal payer) → 403 "Only a platform admin may register as 'platform'". List pins to the caller's tenant; GET :id, cancel and transfer-out return 404 cross-tenant.

Related sections