Add first-class Cloudflare + Fly certificate plumbing for attaching an
arbitrary custom domain to an existing environment, exposed as an Add / Remove Custom Domain action on the Environment Management Modify
tab. Cuts out the manual flyctl certs add + hand-edited DNS dance
previously required per domain cutover.
See screenshot below for how custom domain configuration can now be added to the modify stage of environment set up:
Config model
New CustomDomainStatus enum (pending / attached / failed)
and CustomDomainEntry interface (hostname, addedAt, status,
optional zoneId and message).
EnvironmentConfig gains customDomains?: CustomDomainEntry[],
persisted on the environments config doc under each environment.
ExistingEnvironment carries the list out to the admin UI.
New CustomDomainResponse for add/remove API calls.
Admin UI (Modify tab)
New "Custom Domains" panel on environment-management.ts with:
Hostname input + Add Custom Domain button (disabled while any
operation is busy).
Per-domain table listing hostname, status badge
(attached/pending/failed), added-at, and a Remove action.
Inline error + scrollable session-log message stream wired through
the existing SessionLogsComponent.
addCustomDomain / removeCustomDomain client methods on
EnvironmentSetupService hit the new routes.
Resetting the Modify tab now also clears the custom-domain scratch
state.
ManageAction enum replaces stringly-typed 'modify' / 'destroy'
values across the component.
Environment empty-state now renders the "No environments configured
yet" alert unconditionally when the list is empty, instead of showing
a spinner-in-dropdown during the initial fetch.
Server plumbing
server/lib/cli/commands/subdomain.ts grows two exported functions:
addCustomDomainForEnvironment(envName, hostname)
validates the
hostname (FQDN, no scheme, no path), loads env config, verifies the
Cloudflare token, resolves the zone via the new zoneForHostname,
ensures the Fly app has an IPv4 (allocates a shared one if missing),
upserts A / AAAA records for the hostname, requests a Fly
certificate, reads back the issuance status, and persists a
CustomDomainEntry on the environments config doc regardless of
outcome so the UI shows pending / attached / failed.
Idempotent: existing DNS records and certificates are detected and
skipped.
deletes
matching DNS records, removes the Fly certificate, and clears the
entry from the config doc. Tolerant of missing zones and missing Fly
tokens.
Duplicate-hostname guard refuses to attach a hostname already claimed
by another environment.
Admin attach flow auto-updates group.href in system config when an
apex custom domain is attached, removing a previously-undiscoverable
manual step.
Cloudflare helpers
CloudflareZone model type (id / name / status).
listZones(apiToken, name?) and zoneForHostname(apiToken, hostname)
in cloudflare-dns.ts: walks the label chain
(sub.example.co.uk, example.co.uk, co.uk) and resolves the
first zone Cloudflare reports back under that exact name.
CLOUDFLARE_API_BASE + cloudflareApi.* helper centralises all
https://api.cloudflare.com/client/v4/... URL construction; every
call site updated.
MX record handler zone-awareness
/cloudflare/email-routing/mx-records GET and POST handlers now
resolve the zone for the current baseDomain via zoneForHostname
and operate on that zone instead of the default NGX zone. Previously,
custom-domain MX state was read from (and written to) the wrong zone,
producing false "Missing MX records" status and Cloudflare
"An identical record already exists" rejections.
Routes
POST /environment-setup/add-custom-domain/:environmentName and
POST /environment-setup/remove-custom-domain/:environmentName behind
the existing validateSetupAccess guard. Errors are surfaced verbatim
to the client (including EnvironmentNotFoundError -> 404) so the
session-log panel shows the Cloudflare / Fly reason rather than a
generic 500.
GET /environment-setup/existing-environments now includes
customDomains on each environment so the UI can render the list.
Brevo domain authentication automation
authenticateSendingDomain now accepts optional cloudflareDnsConfig
and baseDomainOverride, so it can authenticate any domain in any
zone - not only the NGX default. When no override is passed it
resolves the correct zone via zoneForHostname(apiToken, domainName)
instead of defaulting to the configured NGX zone; the previous
default caused DKIM/brevoCode TXT writes to collide in the wrong
zone.
New ensureDkimAvailable helper: when Brevo's
GET /senders/domains/configuration returns no DKIM for a registered
domain, delete and re-register, then use the POST /senders/domains
response body's dnsRecords directly (Brevo's own UI's "Authenticate
the domain yourself" shortcut). Eliminates the manual detour into
app.brevo.com previously required for fresh custom domains.
Refactored the inline try/catch let ladders in
authenticateSendingDomain into configureDnsRecords,
requestAuthentication, and resolveCfAndBaseDomain helpers that
return typed results.
brevoDomainsUrl is now included on every non-authenticated outcome
(including DNS-failure early returns), not just the "missing DKIM"
case, so admins always have an escape hatch to Brevo's UI.
brevo/domains/config route returns response: null with HTTP 200
for Brevo 404s instead of bubbling an error, so the frontend console
and MailService logger stop spamming on the expected "not
registered yet" state.
Brevo sending-domain switch
New POST /brevo/domains/switch route + switchBrevoSendingDomain
orchestrator re-authenticate a target domain in Brevo, create the
DKIM/SPF records in the correct Cloudflare zone, and mass-rewrite
every sender from @oldDomain to @newDomain. Reuses the existing
senders helpers (listBrevoSenders, registerBrevoSender,
deleteBrevoSenderById) - no duplication of Brevo API code.
listBrevoSenders extracted to a shared helper; deleteBrevoSenderById
exposed for reuse from the switch flow.
Mail Senders tab surfaces a warning banner + "Switch sending domain
to " button when the Brevo-authenticated domain differs
from group.href. Returned logs[] stream through
SessionLogsComponent; on success, baseDomain, domain status, MX
records and senders all reload.
Button-wrapper disabled-state fix
<app-button-wrapper> no longer uses Bootstrap's .disabled class
(which applies pointer-events: none) to render the disabled state;
replaced with a local is-disabled opacity-only class. Previously,
clicks on visually-disabled buttons fell through the wrapper's
blockClick handler and fired the caller's (click) binding anyway.
Affects all 23 <app-brevo-button> callers across the app with
[disabled] bindings.
Subdomain refactors
server/lib/cli/commands/subdomain.ts
pulled the two remaining
let-driven if/else ladders in the check flow into
reconcileCustomDomainDns (does DNS reconciliation, returns a
dnsProblem | undefined) and classifyCertStatus (pure function
mapping cert + dnsProblem to { status, message }).