Files
member-console/internal/domains/policy.go
T
cgalo5758 c85ac6acdc Add domain claim lifecycle safeguards
Make claim windows and workspace caps configurable, and enforce
initiation
and abandonment budgets without penalizing DNS evidence or system
failures.
Add operator visibility into live claims and default verification to 24
hours.
2026-07-25 00:40:24 -05:00

153 lines
5.6 KiB
Go

package domains
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
// Name rule kinds (domains.name_rules.kind). All three refuse the label for
// members; they differ only in the reason operators see. The vocabulary has
// no "allowed" kind, so an explicit rule can change WHICH refusal applies to
// a label but cannot re-open one the built-in structural rule closes.
const (
RuleReserved = "reserved"
RuleBlocked = "blocked"
RulePremium = "premium"
)
// unavailableMessage is the single member-facing refusal. Every namespace and
// policy verdict collapses to it: a member who learns *why* a name is refused
// learns whether it is claimed, by whom, or which words an operator reserved.
const unavailableMessage = "That name isn't available. Please choose another."
// premiumLabelLength is the built-in structural rule: labels this short under
// an operator root are premium unless an explicit rule says otherwise. Single
// characters are the scarce end of the namespace and are held back by
// default, so a deployment that ships no name_rules rows still has one.
const premiumLabelLength = 1
// MemberMessage collapses a verdict to what a member may see: the empty
// string when the name is available, the validator's own message when the
// name is malformed (shape is public — the member typed it), and one
// indistinguishable refusal for everything else.
//
// Surfaces MUST render this rather than Detail or the claim behind it.
func (a Availability) MemberMessage() string {
switch a.Verdict {
case VerdictAvailable:
return ""
case VerdictInvalid:
if a.Detail != "" {
return a.Detail
}
return unavailableMessage
default:
return unavailableMessage
}
}
// MemberMessage returns the same collapsed refusal for an *Unavailable error,
// so a handler holding only the error need not rebuild an Availability. The
// internal verdict stays on the error for logs and operator surfaces.
func (e *Unavailable) MemberMessage() string { return unavailableMessage }
// budgetRetryFormat matches the deadline rendering on the member Domains
// surface, so the retry time and a claim's expiry read alike. A 24-hour-scale
// answer needs the time of day, not a bare date.
const budgetRetryFormat = "Jan 2, 2006 15:04 MST"
// ceilMinute rounds t up to the next whole minute.
//
// The format above has minute resolution and Format truncates, while the
// budget SQL compares against the exact instant (abandoned_at >= since). A
// member who does exactly as the message says — come back at 15:04 — would be
// refused again for up to 59 more seconds, and the second refusal would repeat
// the same time back at them. Rounding up costs the member under a minute and
// makes the displayed time one they can act on.
func ceilMinute(t time.Time) time.Time {
rounded := t.Truncate(time.Minute)
if rounded.Equal(t) {
return t
}
return rounded.Add(time.Minute)
}
// MemberMessage states the caller's own history and when it may try again.
//
// It is the ONE refusal that is not collapsed to unavailableMessage (design
// D3). Every fact in it belongs to the caller: how many verifications this
// workspace started and gave up, under one of its own scopes, and when the
// oldest ages out. No other workspace's existence, timing, or holdings are
// observable through it — and collapsing it would leave the member with no
// way to tell a refusal they can wait out from one they cannot.
//
// The abandonment message names the escape hatch too: publishing the
// challenge record latches evidence, and an abandonment with evidence is
// never counted.
func (e *BudgetExceeded) MemberMessage() string {
retry := "later"
if !e.RetryAt.IsZero() {
retry = ceilMinute(e.RetryAt).Format(budgetRetryFormat)
}
if e.Scope != "" {
return fmt.Sprintf("You've started and given up %s under %s. You can start another there after %s. "+
"Publishing the TXT record for a domain you're setting up keeps it off this count.",
verificationCount(e.Count), e.Scope, retry)
}
return fmt.Sprintf("You've started %s in the last day. You can start another after %s.",
verificationCount(e.Count), retry)
}
// verificationCount renders the counted entries for the budget messages.
func verificationCount(n int) string {
if n == 1 {
return "1 domain verification"
}
return fmt.Sprintf("%d domain verifications", n)
}
// labelVerdict applies operator name policy to one label carved under the
// operator root rootClaimID: an exact-label rule scoped to that root wins,
// then a global rule (root_claim_id NULL), then the built-in structural
// rule. Policy is consulted only at allocation time, so adding a rule never
// invalidates a claim that already exists.
func labelVerdict(ctx context.Context, q Querier, rootClaimID, label string) (Verdict, error) {
scope := uuid.NullUUID{}
if rootClaimID != "" {
parsed, err := uuid.Parse(rootClaimID)
if err != nil {
return "", fmt.Errorf("domains: parse root claim id %q: %w", rootClaimID, err)
}
scope = uuid.NullUUID{UUID: parsed, Valid: true}
}
rule, err := q.GetNameRuleForLabel(ctx, GetNameRuleForLabelParams{Label: label, RootClaimID: scope})
if errors.Is(err, sql.ErrNoRows) {
if len(label) <= premiumLabelLength {
return VerdictPremium, nil
}
return VerdictAvailable, nil
}
if err != nil {
return "", fmt.Errorf("domains: look up name rule for %q: %w", label, err)
}
switch rule.Kind {
case RuleReserved:
return VerdictReserved, nil
case RuleBlocked:
return VerdictBlocked, nil
case RulePremium:
return VerdictPremium, nil
default:
// The table's CHECK constrains kind to the three above; treat an
// unknown value as a refusal rather than silently allowing it.
return VerdictBlocked, nil
}
}