Skip to content

Domain model

Status: implemented

Scope: domain add, DNS verification, claim transfer. DNS checks are simulated/looked up by the backend; outbound transactional email uses the Resend API.

Auth tables

users

  • id: nanoid
  • email: string (unique, normalized lowercase)
  • password_hash: string (PBKDF2; salt + hash)
  • created_at: timestamp
  • updated_at: timestamp

sessions

  • id: nanoid (session token; value of the session cookie)
  • user_id: nanoid → users (ON DELETE CASCADE)
  • expires_at: timestamp (30 days from creation)
  • created_at: timestamp

Expired sessions are deleted on lookup. Logout deletes the session row and clears the cookie.

Constraints

  • domains.hostname — unique, normalized (lowercase, trimmed, punycode)
  • user_domains (domain_id, user_id) — unique among non-deleted rows — partial unique index user_domains_domain_user_unique (WHERE deleted_at IS NULL)
  • At most one user_domain with status = verified per domain — partial unique index user_domains_one_verified_per_domain
  • At most one pending claim per domain_id — partial unique index domain_claims_one_pending_per_domain (eager-fail expired pending before starting a new claim; see ADR 008)

Current owner: query user_domains where domain_id = ?, status = 'verified', deleted_at IS NULL. No denormalized pointer on domain (ADR 014).

  • No verified row → domain open for add + verify
  • Verified row for another user → domains.create starts or refreshes a pending claim; failed claims restart via domains.verify
  • Claim transfer: revoke incumbent + verify claimant + rotate DKIM in one D1 batch (never two separate steps); conditional updates prevent lost-race partial state (ADR 013)
  • At most one in-window pending claim per hostname; expired pending claims are failed before a competitor can start (ADR 008)
  • At most one pending claim per claimant_user_domain_id (enforce in app)
  • Soft-delete (deleted_at) vs claim loss (revoked + revoked_at) are distinct end states (ADR 010)
  • Ownership (verified) is separate from DNS readiness flags on the API (ADR 011)

Enums

Verification status (per record and derived domain status):

not_started | pending | verified | failed

User domain status:

not_started | pending | verified | failed | revoked

Claim status:

Claims use the same verification status lifecycle: pending | verified | failed. Challenge proof is apex TXT (ADR 007); retries reuse the same code (ADR 009).

Region (set at domain creation):

us-east-1 | eu-west-1 | sa-east-1 | ap-northeast-1

DNS provider (cached from NS lookup for UI):

cloudflare | route53 | godaddy | namecheap | vercel | google_domains | unknown

Add providers as needed; use unknown when NS pattern doesn't match.


domain

Global hostname registry. One row per hostname.

  • id: nanoid
  • hostname: string (unique)
  • region: enum (see above)
  • dns_provider: enum (nullable; cached for UI — see DNS provider enum)
  • dns_provider_checked_at: timestamp (nullable; when provider was last resolved)
  • created_at: timestamp
  • updated_at: timestamp

dns_provider cache: resolved server-side from a live NS lookup during normal verification DNS polls (verify-dns-records.ts), then stored for UI (“DNS hosted with Cloudflare”). Not used for verification logic. Prefer this over caching raw ns_records — the UI only needs the label; detection logic stays in one place on the server.


user_domain

A user's relationship to a domain (owner, former owner, or in-progress setup). Was domain_user.

  • id: nanoid

  • domain_id: nanoid → domain

  • user_id: nanoid → users

  • status: enum (see above)

  • dkim_public_key: string (base64 SPKI public key; used to build DKIM TXT p=…)

  • dkim_private_key_encrypted: string (PKCS8 private key, encrypted)

  • sending_enabled: boolean (default: true; mutable via domains.updateSettings; both flags may be false)

  • receiving_enabled: boolean (default: false; mutable via domains.updateSettings — when enabled, inbound_mx becomes required for verification)

  • dns_records: json (verification state only for email setup records; aggregate domain status derived in app)

    Per record type (dkim_txt, spf_txt, spf_mx, dmarc_txt, inbound_mx):

    • status: enum (verification status)
    • lastError: string (nullable; shown when status = failed)

    Required for verification: spf_txt, dkim_txt, spf_mx when sending_enabled (default). Optional (shown in UI, not blocking): dmarc_txt. Add inbound_mx when receiving_enabled.

    Claim challenge TXT is not stored here — it is verified separately via the claim flow (see domain_claim).

Last DNS check time: dns_checked_at on user_domain (one timestamp per verification poll). Also updated during claim verification polls.

DNS record names and values are not stored. The server builds them at read/verify time via buildDnsRecord() in build-dns-record.ts. The name field is the host label as entered in a DNS provider (@ = zone apex). FQDN examples for example.com:

RecordName (host label)FQDN exampleValue
dkim_txtsendagain._domainkeysendagain._domainkey.example.comp={dkim_public_key}
spf_txtsendsend.example.comglobal SPF constant (v=spf1 include:sendagain.com ~all)
spf_mxsendsend.example.comfeedback.{region-host} (e.g. feedback.us-east-1.sendagain.wellg.dev)
dmarc_txt_dmarc_dmarc.example.comglobal DMARC template (v=DMARC1; p=none)
inbound_mx@example.cominbound.{region-host} (e.g. inbound.us-east-1.sendagain.wellg.dev)

Claim challenge TXT (built via buildClaimDnsRecord(), not buildDnsRecord()):

RecordName (host label)FQDN exampleValue
challenge TXT@example.comsendagain-claim={domain_claim.code}

DKIM selector: hardcoded as sendagain in server code (not stored).

  • verification_started_at: timestamp (nullable; set at claim start or normal verification start)
  • dns_checked_at: timestamp (nullable; last verification poll — all records checked together)
  • verified_at: timestamp (nullable)
  • revoked_at: timestamp (nullable; set when status becomes revoked, e.g. after a successful claim)
  • created_at: timestamp
  • updated_at: timestamp
  • deleted_at: timestamp (nullable; soft delete via domains.delete — not the same as revoked, which is claim-transfer only)

Readiness (API only, not stored): DomainDetail.sendingReady / receivingReady are derived from flags + required dns_records statuses (ADR 011).


domain_claim

Created by domains.create when a user tries to add a hostname already verified by someone else. Inserts a pending claimant user_domain alongside the claim when needed.

  • id: nanoid
  • domain_id: nanoid → domain (denormalized for pending-claim uniqueness)
  • claimant_user_domain_id: nanoid → user_domain (the claimant's in-progress row)
  • incumbent_user_domain_id: nanoid → user_domain (nullable; current verified owner at claim start)
  • code: string (claim token from nanoid(32); used to build the challenge TXT value — uniqueness by generation, not a DB unique index)
  • status: enum (see above)
  • expires_at: timestamp (deadline for the current claim cycle; typically now + CLAIM_VERIFICATION_WINDOW_MSADR 012)
  • verified_at: timestamp (nullable)
  • created_at: timestamp
  • updated_at: timestamp

Verification (time-based, no attempt counter):

  • Set user_domain.verification_started_at at claim start; set expires_at on the claim
  • Poll via cron or domains.verify until success or expires_at, respecting DNS_POLL_INTERVAL_MS
  • DNS miss during the window: claim stays pending; update dns_checked_at only
  • Window ends without success: claim.status = failed; claimant user_domain.status = failed
  • Retry after failure: call domains.verify again; revive the same claim with the same code, a new expires_at, refreshed incumbent, reset verification_started_at, and cleared dns_checked_at
  • Lost transfer race: claim.status = failed; claimant user_domain.status = failed

DNS verification cron

A scheduled task polls DNS for rows due for a check. Indexed queries:

Normal domain verification (email records on user_domains.dns_records):

sql
SELECT * FROM user_domains
WHERE status = 'pending'
  AND deleted_at IS NULL
  AND verification_started_at IS NOT NULL
  AND verification_started_at > :windowStart
  AND NOT EXISTS (
    SELECT 1 FROM domain_claims dc
    WHERE dc.claimant_user_domain_id = user_domains.id
      AND dc.status = 'pending'
  )
  AND (dns_checked_at IS NULL OR dns_checked_at <= :pollCutoff)
  • :windowStart = now - DOMAIN_VERIFICATION_WINDOW_MS
  • :pollCutoff = now - DNS_POLL_INTERVAL_MS
  • Index: user_domains_pending_verification_idx (status, verification_started_at)

Claim verification (challenge TXT via active claim):

sql
SELECT dc.*, ud.*
FROM domain_claims dc
JOIN user_domains ud ON ud.id = dc.claimant_user_domain_id
WHERE dc.status = 'pending'
  AND dc.expires_at > :now
  AND ud.deleted_at IS NULL
  AND (ud.dns_checked_at IS NULL OR ud.dns_checked_at <= :pollCutoff)
  • Index: domain_claims_pending_expires_idx (status, expires_at)
  • Claim deadline is expires_at on the claim; verification_started_at on the claimant user_domain is set at claim start and reset on retry.

Flows

StepWhat happens
Add fresh domainInsert domain + user_domain; generate DKIM keypair (store public + encrypted private); init dns_records state; status → pending with verification_started_at null
First domains.verifySets verification_started_at and runs an immediate DNS check (also covers not_started / restart from failed). Claim start sets the timestamp immediately when the claim is created.
Poll verificationCron (or client) DNS lookup vs built expected records; update dns_records.* state; refresh domain.dns_provider cache on each poll; on all required records verified → user_domain.status = verified, set verified_at; soft-delete competing pending/not_started/failed rows for the same hostname and emit domain.verification.superseded
Add taken domaindomains.create inserts or reuses claimant user_domain + starts domain_claim; user publishes challenge TXT
Claim verify pollDNS lookup for challenge TXT vs domain_claim.code; poll until expires_at
Claim retryAfter failure: call domains.verify again; revive the same claim with the same code, new expires_at, refreshed incumbent, and reset claimant poll state
Claim succeedsRevoke incumbent (revoked, revoked_at); verify claimant; rotate DKIM keypair (one transaction); emit claim.transferred (emails previous owner)
Verification window endsPending normal verification or claim marked failed; emit domain.verification.timed_out or claim.expired (emails the affected user)
Update settingsdomains.updateSettings persists sending_enabled / receiving_enabled; initializes missing required dns_records keys to not_started; never demotes verified ownership
Verify after settingsIf verified but newly required records are incomplete, domains.verify re-checks DNS and updates record state only (keeps verified)
Delete domainSoft-delete user_domain (deleted_at); fail pending claim if claimant; shared domains row kept; same user may re-add hostname

Events & notifications

Domain services and cron emit in-process events; email is a side effect, not part of the domain transaction.

Locations: apps/server/src/events/ (@/events), apps/server/src/notifications/ (@/notifications).

Wiring: createDomainServiceContext / createDomainCronContext create an EventBus (unless one is injected) and call registerEmailListeners. Handlers are awaited; failures are logged and do not fail the domain path. Outbound email uses Resend when RESEND_API_KEY is set; otherwise the mailer is a no-op.

EventEmitted whenEmail
claim.transferredClaim TXT verified and transfer batch succeedsmailer.sendDomainClaimedByAnotherAccountDomainClaimed template (previous owner)
domain.verification.supersededAnother user verifies first; competing pending/not_started/failed user_domain rows are soft-deletedsame claimed mailer method (each superseded user)
claim.expiredClaim window ends (expires_at passed) via cron or domains.verifymailer.sendDomainVerificationFollowUpDomainVerificationFollowUp
domain.verification.timed_outNormal verification window ends via cron or domains.verifysame follow-up mailer method

VerificationFollowUpRecipient payload: { userId, email, hostname, userDomainId } (also used by domain.verification.superseded). claim.transferred payload includes previous-owner identity and both user-domain ids.