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