AUTH4D-339 Expiring unproven domain claims
Custom hostnames (AUTH4D-156) and sending domains (AUTH4D-168) are unique across all projects and are reserved in D1 before anyone has proven they own the DNS name. Before this change a reservation never lapsed, so any developer could block a hostname or domain for every other project. Now a claim that never proves ownership lapses after a fixed time. No contract or migration changes.
Rules
| Claim | Stale when | TTL |
|---|---|---|
| Custom hostname | not active, never active (activated_at is null), added before the TTL |
7 days |
| Sending domain | not verified, never verified (verified_at is null), added before TTL |
7 days |
The age is measured from created_at, which status checks never change, so refreshing a claim does
not keep it alive. A hostname that was active once, or a domain that verified once, is never stale,
even if the provider later reports a failure; only its owner can remove it.
activated_at now stays set in D1 after a hostname stops being active, so the store remembers the
proven claim. The API is unchanged: activatedAt is still null whenever status is not active,
and it is reset to the new time when a hostname becomes active again.
Taking over a stale claim
POST /management/tenants/{tenantId}/custom-domains and
POST /management/tenants/{tenantId}/sending-domain free a stale claim on the requested name
before reserving it, for any project (including the caller’s own). The provider record goes first
(Cloudflare custom hostname or Resend domain, so old ownership and DKIM records are gone), then the
row is deleted under the same staleness guard. A claim that became active or verified in between is
kept and the request gets the usual 409 hostname_taken or 409 domain_unavailable.
If the provider cannot release the old record the stale row stays and the request answers
503 temporarily_unavailable; it can be retried. Fresh, active and once-verified claims answer 409 as
before, and the answer never names the other project.
Rate limit
Adding a custom hostname now has a per-project budget of 20 reservations per 24 hours
(CREATE_BUDGET in apps/api/src/modules/custom-domains), on top of the 3-hostname limit, so a
project cannot churn through names. Over budget: 429 rate_limited with Retry-After: 3600.
Sending domains keep their existing budget of 10 adds per project per day.
Scheduled cleanup
The lifecycle cron (*/10) runs expireCustomHostnameClaims and expireSendingDomainClaims after
the sending-domain refresh. Each run releases up to 25 stale claims of each kind, oldest first,
the same way as a takeover. It does nothing when the provider is not configured (custom domains
off, or no RESEND_DOMAINS_API_KEY), so a claim whose provider record cannot be removed is kept.
Audit
Each released claim writes one event to the owning project, with no actor:
| Event | Metadata |
|---|---|
management.custom_domain.deleted |
hostname, previous_status, reason: expired |
management.sending_domain.removed |
domain, status, reason: expired |
Tests
tests/integration/auth4d-339.domain-claims.test.ts: takeover of a stale pending hostname and an
unverified sending domain by another project, refusal for active and once-proven claims, the
provider outage path, the scheduled cleanup and the custom-hostname create budget.
Source: docs/api-spec/auth4d-339.md