Manual · docs/domain-registration.md

Domain registration and DNS

Pushleft purchases domains through Openprovider and hosts their authoritative DNS in one configured Cloudflare account. Both integrations run only in the backend. Wrangler deploys the application; it is not involved in domain purchases or customer zone creation.

Durable workflow

The API creates a short-lived, tenant-scoped quote with integer minor-unit amounts. Checkout rechecks availability and price outside the database transaction, locks the normalized domain, debits the tenant billing balance idempotently, stores the customer’s registrant contact, and enqueues provisioning.

The controller persists these steps:

payment_confirmed → registering → registered → creating_zone → configuring_dns → awaiting_activation → active

registration_unknown, price_acceptance_required, manual_intervention, and failed preserve the last successful step. An uncertain registration response is reconciled through Openprovider before another purchase can occur. Once registration succeeds, a later DNS error never deletes or repurchases the domain. Definite pre-registration failure credits the internal balance and writes an auditable refund transaction.

Cloudflare zone creation has a persisted attempt marker. A zone that existed before Pushleft attempted creation is not adopted automatically. After an uncertain create response, reconciliation is limited to the configured account and the domain relationship recorded by the in-progress order.

Configuration

  • OPENPROVIDER_ENVIRONMENT: sandbox or live.
  • OPENPROVIDER_USERNAME and OPENPROVIDER_PASSWORD: backend-only credentials for the chosen environment.
  • OPENPROVIDER_BOOTSTRAP_NS_GROUP: an existing nameserver group used only for the registration request. The controller replaces it with Cloudflare’s exact assigned nameservers.
  • OPENPROVIDER_MAX_PURCHASE_MINOR: fail-closed provider purchase ceiling in integer minor units.
  • PUSHLEFT_DOMAIN_QUOTE_TTL_MINUTES: accepted quote window, clamped to 5–60 minutes.
  • CLOUDFLARE_ACCOUNT_ID: the single account where customer zones are created.
  • CLOUDFLARE_API_TOKEN: a scoped backend token with Zone Edit and DNS Edit for that account.

Openprovider sandbox uses separate credentials and does not create live registry objects. Live use also requires an appropriately funded Openprovider account and TLD agreements. The adapter fetches each TLD’s restrictions rather than assuming .com requirements.

Domain names are normalized to lowercase ASCII/Punycode for uniqueness while retaining their Unicode display form. Internationalized registrations require the registry’s idn_script; Pushleft passes it into availability, price, and registration calls and rejects a missing or unsupported script instead of guessing.

Billing and renewal boundary

This repository did not contain a card processor or recurring-charge workflow. Domain checkout therefore uses the existing durable billing ledger as an authorized balance debit; production must connect a payment-confirmation/top-up source before customers can fund that balance. No public balance-mutation endpoint is exposed.

Renewal is intentionally shown as manual. Registrar autorenew is disabled; the controller reconciles expiry, registrar status, verification, and provider renewal settings daily, and the customer UI shows an in-app warning during the final 60 days. Automatic renewal must not be enabled until a scheduled charging flow, customer mandate, outbound notification policy, insufficient-funds handling, and idempotent renewal purchase path are implemented.

DNS safety

Customer record sets support A, AAAA, CNAME, MX, and TXT. Names must remain inside the managed zone; TTL is automatic (1) or 60–86400 seconds; MX requires priority; only A, AAAA, and CNAME can be proxied. Customer mutations resolve the provider zone through the tenant-owned local zone and never accept a Cloudflare zone ID. Application-managed records cannot be edited and require explicit confirmation before deletion. DNSSEC stays disabled until DS synchronization with Openprovider is implemented.

Provider response snapshots and events exclude credentials and registrant PII. Customer-facing errors are allowlisted; detailed provider messages remain server-side.

Custom domains on an environment

Registering a domain (above) and serving a hostname are separate. Any hostname the tenant controls, registered through Pushleft or elsewhere, can be attached to an environment:

pl domain add www.example.com            # this checkout's project and preview/<branch>
pl domain add www.example.com --production
pl domain list | show <id> | remove <id>

POST /v1/custom-domains {hostname, project, environment} (member and above; viewers can list and show) returns the claim and its proof options:

  • TXT _pushleft-challenge.<hostname> with the claim’s token (works for apex names), or
  • CNAME <hostname> → the environment’s preview hostname, <branch>--<project>.<base> (also points traffic at the ingress).

The scheduler checks the record with a real DNS query (bounded, retried with backoff for 72 hours), then routes the verified name with every release of that environment. Caddy obtains its certificate by ACME HTTP-01 or TLS-ALPN-01, so the name must resolve to the ingress (A/AAAA or the CNAME) and ports 80/443 must be reachable. Nothing is routed and no certificate is requested before verification. A verified name belongs to one organization and environment until it is removed; another organization’s claim for it fails with hostname is already verified by another organization or environment. Names under the preview base domain, .localhost, IP addresses and wildcards are refused. Statuses (pending_verification, verified, active, failed, removed) and custom_domain_events are history; removing and re-adding creates a new claim with a new token. Pushleft does not create these DNS records for the tenant, including in Cloudflare zones it manages: the record-set API is the way to add them there. Runbook: custom-domain verification.

Rendered from the repository at build time. Edit docs/domain-registration.md, not this page.