Files
member-console/internal/server/member_domains.go
T
cgalo5758 0affda70bd Centralize the external-claim entitlement gate in the registry
Registry.ClaimExternal now enforces the plan gate itself via an injected
domains.ExternalClaimGate (pre-lock, typed refusals), so every entry
point — and any future consumer — inherits it from the allocation API.
One constructor in internal/server builds the gate from the entitlements
querier and connect target; it is injected into the member-facing
registry constructions in server.go and fedwiki.go and drives affordance
rendering on both surfaces. The duplicated helpers and resource-key
constants in fedwiki web and member_domains are gone; fedwiki no longer
reads entitlement tables for this gate at all.

Archives the change with the domains-registry spec delta (enforcement
location is now requirement-level: registry-inherited, surfaces derive).
Closes the entitlement-gate placement debt in issues.md; files the
separately-discovered operator force-release dead-end affordance bug
that a placed claim exposed in the domains walkthrough.
2026-07-26 03:33:16 -05:00

751 lines
29 KiB
Go

// Member domain claims at point of use (dissolve-member-domains, née the
// /domains page per domains-registry design D9): the HTMX partials hosted by
// the sites surface's Domains section and the dashboard's pending-claim
// notice — the workspace's live claims and one claim's DNS instructions with
// its per-record probe state. Claim initiation lives in the site-creation
// flow; the AddClaim POST remains routed and fully gated for compatibility,
// but no surface renders its form anymore.
//
// Every claim operation is owner-scoped through the registry, which reports a
// claim owned by another workspace and a claim that does not exist
// identically (domains.ErrNotFound), so an id probe never becomes an
// existence oracle. Claim initiation mirrors FedWiki's create glue
// (internal/integrations/fedwiki/web/customdomain.go) step for step — one
// claim model, one verification workflow, two entry points — with the single
// distinction this surface owes its member: their OWN pending claim at the
// name is named and linked, instead of collapsing to the generic refusal that
// any other workspace's claim gets.
package server
import (
"context"
"errors"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/dnsname"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
wfdomains "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
"go.temporal.io/sdk/client"
)
// Member-facing copy for the two ways the add-a-domain affordance can be
// closed. They are deliberately different: only one of them is something the
// member can act on.
const (
planGateMessage = "Adding a domain you own isn't part of your current plan. " +
"Upgrade from the Products page to enable it."
noConnectTargetMessage = "Domains you own can't be connected on this deployment yet. " +
"Ask your operator to configure a connect target."
)
// MemberDomainsHandler serves the /domains page's partials.
type MemberDomainsHandler struct {
// DomainsQ is the registry store, used for the reads that are plain
// lookups rather than allocation decisions (naming the placements that
// block a release).
DomainsQ domains.Querier
// Registry is the allocation API: every claim read and write on this
// surface goes through it, so the ownership and disjointness rules are
// enforced in exactly one place.
Registry *domains.Registry
TemporalClient client.Client // nil if Temporal isn't configured
AuthConfig *auth.Config
Logger *slog.Logger
// ConnectTarget is the DNS target members point external domains at.
// Empty disables external claims deployment-wide (design D4/D7). Still
// held here for rendering the connect record's value; the gate below is
// what decides admission.
ConnectTarget string
Templates *SafeTemplates
// gate is the shared external-claim gate
// (centralize-external-claim-gate), built in the constructor from the
// same querier + connect target the registry's enforcement uses.
gate domains.ExternalClaimGate
}
// MemberDomainsConfig holds configuration for the member domains handler.
type MemberDomainsConfig struct {
DomainsQ domains.Querier
Registry *domains.Registry
EntitlementsQ entitlements.Querier
TemporalClient client.Client
AuthConfig *auth.Config
Logger *slog.Logger
ConnectTarget string
}
// NewMemberDomainsHandler creates a new MemberDomainsHandler.
func NewMemberDomainsHandler(cfg MemberDomainsConfig) (*MemberDomainsHandler, error) {
templateSubFS, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
return nil, err
}
tmpl, err := template.New("member").Funcs(template.FuncMap{
"routeURL": web.RouteURL,
}).ParseFS(templateSubFS, "member_*.html")
if err != nil {
return nil, err
}
return &MemberDomainsHandler{
DomainsQ: cfg.DomainsQ,
Registry: cfg.Registry,
TemporalClient: cfg.TemporalClient,
AuthConfig: cfg.AuthConfig,
Logger: cfg.Logger,
ConnectTarget: strings.TrimSpace(cfg.ConnectTarget),
Templates: NewSafeTemplates(tmpl, cfg.Logger),
gate: NewExternalClaimGate(cfg.EntitlementsQ, cfg.ConnectTarget),
}, nil
}
// RegisterRoutes registers the member domains partial routes. The claim
// status route is a GET and stays one: reopening DNS instructions is a pure
// read that mints nothing (spec: "reopening them SHALL NOT re-initiate a
// claim or mint new records").
func (h *MemberDomainsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /partials/domains/claims", h.GetClaims)
mux.HandleFunc("POST /partials/domains/claims", h.AddClaim)
mux.HandleFunc("GET /partials/domains/claims/{claimID}", h.GetClaimStatus)
mux.HandleFunc("POST /partials/domains/claims/{claimID}/check", h.CheckClaimNow)
mux.HandleFunc("POST /partials/domains/claims/{claimID}/cancel", h.CancelClaim)
mux.HandleFunc("POST /partials/domains/claims/{claimID}/release", h.ReleaseClaim)
}
// --- View models ---
// MemberDomainClaimRow is one row of the claims table.
type MemberDomainClaimRow struct {
ClaimID string
Root string
// KindLabel is member-facing wording, not the stored kind: operator
// roots and carved member claims are both "Hosted" (names the deployment
// runs), external claims are "Custom" (a domain the member owns).
KindLabel string
KindClass string
Status string
StatusClass string
Placements int64
// External marks a claim whose DNS instructions are worth opening.
External bool
// Pending distinguishes a claim still waiting on DNS from a settled one,
// which is what the instructions link is called: records to publish
// versus records to review. Cancel itself lives in the instructions view,
// where the pending-only guard is applied (design D4).
Pending bool
// CanRelease is true for an active member/external claim holding no
// placement — the release guard, mirrored server-side.
CanRelease bool
}
// MemberDomainsData feeds member_domains.html.
type MemberDomainsData struct {
Claims []MemberDomainClaimRow
// CanAddExternal renders the add-a-domain affordance. Both halves of the
// gate must hold (entitlement granted AND connect target configured);
// every POST re-enforces it, so this is presentation only.
CanAddExternal bool
Error string
Notice string
// FormDomain preserves what the member typed when the form comes back
// with a refusal.
FormDomain string
FormError string
// FormClaimID is set only when the refusal was the member's OWN pending
// claim at that name, so the form can link to its instructions instead of
// leaving them at a dead end. Any other holder collapses to FormError.
FormClaimID string
}
// MemberDomainRecordView is one row of the DNS instructions table: a record
// plus its latest probe result.
type MemberDomainRecordView struct {
Kind string // record type shown to the member: TXT / CNAME
Purpose string // what this record is for, one line
Name string // DNS name to create the record at
Value string // required record value
State string // domains.ProbeState*: unchecked|missing|mismatch|match|error
Observed []string // what the last probe actually saw (mismatch diagnostics)
// Optional marks a record that is guidance, not a requirement: the
// wildcard row (design D9). Nothing probes it and verification never
// waits on it, so its status column reads "optional".
Optional bool
}
// MemberDomainClaimData feeds member_domains_claim.html.
type MemberDomainClaimData struct {
ClaimID string
Domain string
Records []MemberDomainRecordView
StatusText string
LastChecked string // "" until the first probe lands
ExpiresAt string
// Polling drives the 5s in-place refresh; true only while pending.
Polling bool
// Verified marks an active claim. Verification creates nothing (design
// D4) — site creation lives on the provider's own card — so the verified
// branch says where to go rather than offering a create here.
Verified bool
Placed bool
Expired bool
Canceled bool
Released bool
// Hosted marks a claim that is not an external one: it carries no
// challenge and has no records to publish.
Hosted bool
Error string
}
// --- Presentation helpers ---
// domainKindLabel maps a stored claim kind to member-facing wording plus its
// badge class. Members never see the registry's vocabulary: what matters to
// them is whether a name is one the deployment hosts or one they own.
func domainKindLabel(kind string) (string, string) {
switch kind {
case domains.KindExternal:
return "Custom", "text-bg-primary"
default: // operator_root, member
return "Hosted", "text-bg-secondary"
}
}
// domainStatusLabel maps a claim status to member-facing wording plus its
// badge class.
func domainStatusLabel(status string) (string, string) {
switch status {
case domains.StatusPending:
return "Awaiting DNS", "text-bg-warning"
case domains.StatusActive:
return "Active", "text-bg-success"
case domains.StatusExpired:
return "Expired", "text-bg-secondary"
case domains.StatusCanceled:
return "Canceled", "text-bg-secondary"
case domains.StatusReleased:
return "Released", "text-bg-secondary"
default:
return status, "text-bg-secondary"
}
}
// newMemberDomainRecords builds the records table in display order: the TXT
// challenge (the only record verification gates on), the connect record, and
// the OPTIONAL wildcard row (design D9) that lets any subdomain of the claim
// resolve without a new DNS record — which is exactly the shape a claim has,
// since every name inside it places instantly.
func newMemberDomainRecords(domain, txtName, txtValue, connectTarget string) []MemberDomainRecordView {
return []MemberDomainRecordView{
{
Kind: "TXT",
Purpose: "Proves you control the domain",
Name: txtName,
Value: txtValue,
State: domains.ProbeStateUnchecked,
},
{
Kind: "CNAME",
Purpose: "Points the domain at your site",
Name: domain,
Value: connectTarget,
State: domains.ProbeStateUnchecked,
},
{
Kind: "CNAME",
Purpose: "Optional — lets any subdomain work without new DNS records",
Name: "*." + domain,
Value: connectTarget,
State: domains.ProbeStateUnchecked,
Optional: true,
},
}
}
// splitProbeObserved undoes the probe activity's newline join.
func splitProbeObserved(joined string) []string {
if joined == "" {
return nil
}
return strings.Split(joined, "\n")
}
// --- Reads ---
// externalClaimsEnabled is the affordance read over the shared gate
// (centralize-external-claim-gate): a typed refusal is simply "closed", and
// only an infrastructure failure surfaces as an error. Enforcement no longer
// lives here — the registry's ClaimExternal carries the same gate.
func (h *MemberDomainsHandler) externalClaimsEnabled(ctx context.Context, workspaceID string) (bool, error) {
err := h.gate(ctx, workspaceID)
switch {
case err == nil:
return true, nil
case externalClaimGateRefused(err):
return false, nil
default:
return false, err
}
}
// surfaceData assembles the whole Domains surface: the gate plus the
// workspace's live claims with their placement counts.
func (h *MemberDomainsHandler) surfaceData(ctx context.Context, workspaceID string) MemberDomainsData {
data := MemberDomainsData{}
enabled, err := h.externalClaimsEnabled(ctx, workspaceID)
if err != nil {
// A failed gate read closes the affordance rather than opening it:
// the POST would refuse anyway, and a dead button is worse than none.
h.Logger.Error("failed to read external-domain entitlement",
slog.String("workspace_id", workspaceID), slog.Any("error", err))
}
data.CanAddExternal = enabled
if h.Registry == nil {
data.Error = "Domains aren't available right now."
return data
}
rows, err := h.Registry.ListLiveClaims(ctx, workspaceID)
if err != nil {
h.Logger.Error("failed to list domain claims",
slog.String("workspace_id", workspaceID), slog.Any("error", err))
data.Error = "Failed to load your domains."
return data
}
for _, row := range rows {
kindLabel, kindClass := domainKindLabel(row.Kind)
statusLabel, statusClass := domainStatusLabel(row.Status)
external := row.Kind == domains.KindExternal
data.Claims = append(data.Claims, MemberDomainClaimRow{
ClaimID: row.ClaimID,
Root: row.RootFqdn,
KindLabel: kindLabel,
KindClass: kindClass,
Status: statusLabel,
StatusClass: statusClass,
Placements: row.PlacementCount,
External: external,
Pending: row.Status == domains.StatusPending,
CanRelease: row.Status == domains.StatusActive &&
row.Kind != domains.KindOperatorRoot &&
row.PlacementCount == 0,
})
}
return data
}
// claimStatusData renders one claim into the instructions/status view model.
// The probe columns live on the claim row itself, so the verification
// workflow's writes and this read touch the same record.
func (h *MemberDomainsHandler) claimStatusData(ctx context.Context, claim domains.Claim, statusText string) MemberDomainClaimData {
data := MemberDomainClaimData{
ClaimID: claim.ClaimID,
Domain: claim.RootFqdn,
StatusText: statusText,
Verified: claim.Status == domains.StatusActive,
Expired: claim.Status == domains.StatusExpired,
Canceled: claim.Status == domains.StatusCanceled,
Released: claim.Status == domains.StatusReleased,
Polling: claim.Status == domains.StatusPending,
Hosted: claim.Kind != domains.KindExternal,
}
if !data.Hosted {
records := newMemberDomainRecords(claim.RootFqdn,
wfdomains.ChallengeRecordName(claim.RootFqdn),
wfdomains.ChallengeRecordValue(claim.Token),
h.ConnectTarget)
records[0].State = claim.TxtState
records[0].Observed = splitProbeObserved(claim.TxtObserved)
records[1].State = claim.ConnectState
records[1].Observed = splitProbeObserved(claim.ConnectObserved)
data.Records = records
}
if claim.ExpiresAt.Valid {
// With the time of day: the verification window is hours, so a bare
// date would tell a member they have until the end of a day they do
// not have.
data.ExpiresAt = claim.ExpiresAt.Time.Format("Jan 2, 2006 15:04 MST")
}
if claim.LastCheckedAt.Valid {
data.LastChecked = claim.LastCheckedAt.Time.Format("Jan 2, 2006 15:04 MST")
}
// A placement at the root means a site already sits at the name, which
// changes what the verified branch should say.
if data.Verified && h.Registry != nil {
if placement, err := h.Registry.PlacementAt(ctx, claim.RootFqdn); err != nil {
h.Logger.Warn("failed to check placement for verified claim", slog.Any("error", err))
} else if placement != nil {
data.Placed = true
}
}
return data
}
// --- Handlers ---
// session resolves the caller's session, writing the 401 itself when absent.
func (h *MemberDomainsHandler) session(w http.ResponseWriter, r *http.Request) (*auth.UserSession, bool) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return nil, false
}
return session, true
}
// ownedClaim loads the claim named in the request path and enforces workspace
// ownership, writing the HTTP error itself when it fails. A claim another
// workspace owns is indistinguishable from one that does not exist (the
// registry collapses both to ErrNotFound).
func (h *MemberDomainsHandler) ownedClaim(w http.ResponseWriter, r *http.Request) (domains.Claim, bool) {
session, ok := h.session(w, r)
if !ok {
return domains.Claim{}, false
}
if h.Registry == nil {
http.NotFound(w, r)
return domains.Claim{}, false
}
claim, err := h.Registry.ClaimByID(r.Context(), session.WorkspaceID, r.PathValue("claimID"))
if err != nil {
if !errors.Is(err, domains.ErrNotFound) {
h.Logger.Error("failed to load domain claim", slog.Any("error", err))
}
http.NotFound(w, r)
return domains.Claim{}, false
}
return claim, true
}
// GetClaims handles GET /partials/domains/claims — the surface's list view.
func (h *MemberDomainsHandler) GetClaims(w http.ResponseWriter, r *http.Request) {
session, ok := h.session(w, r)
if !ok {
return
}
h.Templates.Render(w, "member_domains.html", h.surfaceData(r.Context(), session.WorkspaceID))
}
// GetClaimStatus handles GET /partials/domains/claims/{claimID} — the DNS
// instructions view and the poll target the pending view swaps itself with.
// It re-reads the claim and mints nothing.
func (h *MemberDomainsHandler) GetClaimStatus(w http.ResponseWriter, r *http.Request) {
claim, ok := h.ownedClaim(w, r)
if !ok {
return
}
h.renderClaimStatus(w, r, claim, "")
}
// renderClaimStatus renders the right branch for the claim's current state.
// statusText overrides the default pending copy when non-empty.
func (h *MemberDomainsHandler) renderClaimStatus(w http.ResponseWriter, r *http.Request, claim domains.Claim, statusText string) {
if claim.Status == domains.StatusPending && statusText == "" {
statusText = "Waiting for your DNS records…"
}
h.Templates.Render(w, "member_domains_claim.html", h.claimStatusData(r.Context(), claim, statusText))
}
// AddClaim handles POST /partials/domains/claims — the add-a-domain form.
// The gate is re-enforced here (the affordance's absence is presentation, not
// protection), then the name is classified and, when free, claimed and its
// verification workflow started.
func (h *MemberDomainsHandler) AddClaim(w http.ResponseWriter, r *http.Request) {
session, ok := h.session(w, r)
if !ok {
return
}
ctx := r.Context()
// Normalize before anything reads the name: the registry stores and
// compares normalized names, so an absolute FQDN ("wiki.example.org.")
// must lose its root label here or the claim row and the echoed form
// value disagree.
root := dnsname.Normalize(r.FormValue("domain"))
refuse := func(message string, claimID string) {
data := h.surfaceData(ctx, session.WorkspaceID)
data.FormDomain = root
data.FormError = message
data.FormClaimID = claimID
h.Templates.Render(w, "member_domains.html", data)
}
// Pre-flight over the shared gate — presentation only, since the
// registry's ClaimExternal enforces the same gate regardless
// (centralize-external-claim-gate). The two refusals get different copy
// because only one of them is the member's to fix.
switch err := h.gate(ctx, session.WorkspaceID); {
case errors.Is(err, domains.ErrNoConnectTarget):
refuse(noConnectTargetMessage, "")
return
case errors.Is(err, domains.ErrExternalClaimsNotEntitled):
refuse(planGateMessage, "")
return
case err != nil:
h.Logger.Error("failed to read external-domain entitlement",
slog.String("workspace_id", session.WorkspaceID), slog.Any("error", err))
refuse("We couldn't check your plan just now. Please try again.", "")
return
}
if root == "" {
refuse("Enter the domain you want to use.", "")
return
}
if h.Registry == nil {
refuse("Domains aren't available right now.", "")
return
}
// Classify before claiming, so the member's own pending claim can be
// named and linked. Every other refusal collapses to the registry's
// single message — a member must not learn who holds a name, or that
// anyone is verifying it.
av, err := h.Registry.CheckAvailability(ctx, root, session.WorkspaceID)
if err != nil {
h.Logger.Error("failed to check domain availability", slog.Any("error", err))
refuse("We couldn't check that domain. Please try again.", "")
return
}
switch {
case av.Verdict != domains.VerdictAvailable:
if av.Claim != nil && av.Claim.WorkspaceID == session.WorkspaceID && av.Claim.Status == domains.StatusPending {
refuse("You're already verifying that domain.", av.Claim.ClaimID)
return
}
refuse(av.MemberMessage(), "")
return
case av.Claim == nil:
// Free: claim it below.
case av.Claim.Kind == domains.KindOperatorRoot:
refuse("That domain is one of ours — create a site with a site name instead of a domain you own.", "")
return
default:
// Inside the caller's own ACTIVE claim: a name there is a placement,
// not a second claim, and needs no verification at all.
refuse("You already have that domain. Any name inside it is yours — create a site with it from your dashboard.", "")
return
}
claim, message := h.startClaimVerification(ctx, session.WorkspaceID, root)
if claim == nil {
refuse(message, "")
return
}
h.renderClaimStatus(w, r, *claim, "Waiting for your DNS records…")
}
// startClaimVerification creates the pending external claim and starts its
// verification workflow. The sequence mirrors FedWiki's create glue exactly
// (internal/integrations/fedwiki/web/customdomain.go): the registry
// re-evaluates the name under its own lock, so a lost race surfaces as a
// refusal rather than a corrupt claim, and a workflow that fails to start
// frees the name again instead of parking it for the whole window. Returns
// the claim, or a member-facing rejection message.
func (h *MemberDomainsHandler) startClaimVerification(ctx context.Context, workspaceID, root string) (*domains.Claim, string) {
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
return nil, "Domain verification is not available right now."
}
claim, err := h.Registry.ClaimExternal(ctx, workspaceID, root)
if err != nil {
return nil, h.claimErrorMessage(err)
}
input := wfdomains.VerifyClaimWorkflowInput{
ClaimID: claim.ClaimID,
WorkspaceID: workspaceID,
Root: claim.RootFqdn,
Token: claim.Token,
ConnectTarget: h.ConnectTarget,
ExpiresAt: claim.ExpiresAt.Time,
}
options := client.StartWorkflowOptions{
ID: wfdomains.VerifyClaimWorkflowIDPrefix + claim.ClaimID,
TaskQueue: queues.Main,
}
if _, err := h.TemporalClient.ExecuteWorkflow(ctx, options, wfdomains.VerifyClaimWorkflow, input); err != nil {
h.Logger.Error("failed to start claim verification workflow", slog.Any("error", err))
// Free the name: a pending claim nothing is polling for would hold it
// for the full window. `canceled`, not `expired` — the window did not
// elapse — and the mark is pending-guarded, so it cannot disturb a
// claim that somehow raced to active. The SYSTEM cancel: this is our
// failure, so it must not debit the member's abandonment ledger.
if _, cErr := h.Registry.CancelClaimSystem(ctx, workspaceID, claim.ClaimID); cErr != nil {
h.Logger.Error("failed to cancel orphaned claim", slog.Any("error", cErr))
}
return nil, "We couldn't start verification. Please try again."
}
return &claim, ""
}
// claimErrorMessage collapses a registry allocation error to what a member may
// read. Only the shape and resource-guard errors carry their own reason; every
// namespace refusal renders the registry's single indistinguishable message.
func (h *MemberDomainsHandler) claimErrorMessage(err error) string {
var unavailable *domains.Unavailable
var budget *domains.BudgetExceeded
switch {
case errors.As(err, &unavailable):
return unavailable.MemberMessage()
case errors.As(err, &budget):
// The documented exception to the collapse rule: the abandonment and
// initiation budgets report the caller's OWN history and retry time,
// which discloses nothing about any other workspace and is the only
// thing the member can act on (design D3).
return budget.MemberMessage()
case errors.Is(err, domains.ErrPendingClaimCap):
return "You already have several domain verifications in progress. " +
"Cancel one, or wait for one to finish, before starting another."
case errors.Is(err, domains.ErrInvalidName):
// The wrapped text IS the member-facing shape message from
// internal/dnsname (the member typed the name; its shape is not a
// secret).
return strings.TrimPrefix(err.Error(), domains.ErrInvalidName.Error()+": ")
case errors.Is(err, domains.ErrInsideOperatorRoot):
return "That domain is one of ours — create a site with a site name instead of a domain you own."
case errors.Is(err, domains.ErrAlreadyInsideClaim):
return "You already have that domain. Create your site with it from your dashboard."
default:
h.Logger.Error("failed to create external domain claim", slog.Any("error", err))
return "We couldn't start verification. Please try again."
}
}
// CheckClaimNow handles POST /partials/domains/claims/{claimID}/check —
// signals the claim's verification workflow to probe immediately instead of
// waiting out its backoff. Best-effort: a signal failure (e.g. the workflow
// just finished) still renders the current state, and the workflow's own
// signal drain coalesces rapid clicks.
func (h *MemberDomainsHandler) CheckClaimNow(w http.ResponseWriter, r *http.Request) {
claim, ok := h.ownedClaim(w, r)
if !ok {
return
}
if claim.Status == domains.StatusPending && h.TemporalClient != nil {
if err := h.TemporalClient.SignalWorkflow(r.Context(),
wfdomains.VerifyClaimWorkflowIDPrefix+claim.ClaimID, "",
wfdomains.CheckNowSignal, nil); err != nil {
h.Logger.Warn("failed to signal check-now", slog.Any("error", err))
}
}
h.renderClaimStatus(w, r, claim, "Checking your DNS records now…")
}
// CancelClaim handles POST /partials/domains/claims/{claimID}/cancel. The
// claim is marked first — canceled frees the name the moment the update
// commits — and the workflow is then canceled best-effort. Zero rows updated
// means the cancel lost its race to verification or expiry, and the member
// simply sees the claim's current state (spec: "Cancel loses the race to
// verification").
func (h *MemberDomainsHandler) CancelClaim(w http.ResponseWriter, r *http.Request) {
claim, ok := h.ownedClaim(w, r)
if !ok {
return
}
canceled, err := h.Registry.CancelClaim(r.Context(), claim.WorkspaceID, claim.ClaimID)
if err != nil {
h.Logger.Error("failed to cancel claim", slog.Any("error", err))
h.renderClaimStatus(w, r, claim, "We couldn't cancel this verification. Please try again.")
return
}
if canceled {
if h.TemporalClient != nil {
if cErr := h.TemporalClient.CancelWorkflow(r.Context(),
wfdomains.VerifyClaimWorkflowIDPrefix+claim.ClaimID, ""); cErr != nil {
// The claim is already terminal; an orphaned poller records
// nothing (probe writes are pending-guarded) and expires on
// its own.
h.Logger.Warn("failed to cancel verification workflow", slog.Any("error", cErr))
}
}
// The update committed — the outcome is known without a re-read, so a
// transient read failure can't render a stale still-pending view.
claim.Status = domains.StatusCanceled
h.renderClaimStatus(w, r, claim, "")
return
}
// Zero rows: the cancel lost a race to verified/expired — show whatever
// the claim is now.
if fresh, fErr := h.Registry.ClaimByID(r.Context(), claim.WorkspaceID, claim.ClaimID); fErr == nil {
claim = fresh
}
h.renderClaimStatus(w, r, claim, "")
}
// ReleaseClaim handles POST /partials/domains/claims/{claimID}/release —
// giving up a claim the workspace no longer wants. The registry refuses while
// any placement remains (spec: "Release is guarded by placements"); this
// handler turns that refusal into the actionable message the member needs.
func (h *MemberDomainsHandler) ReleaseClaim(w http.ResponseWriter, r *http.Request) {
session, ok := h.session(w, r)
if !ok {
return
}
if h.Registry == nil {
http.NotFound(w, r)
return
}
ctx := r.Context()
claimID := r.PathValue("claimID")
err := h.Registry.ReleaseClaim(ctx, session.WorkspaceID, claimID)
if errors.Is(err, domains.ErrNotFound) {
// Foreign or nonexistent — the same answer for both.
http.NotFound(w, r)
return
}
data := h.surfaceData(ctx, session.WorkspaceID)
switch {
case err == nil:
data.Notice = "Domain released. It's free to claim again."
case errors.Is(err, domains.ErrClaimHasPlacements):
data.Error = h.placementGuardMessage(ctx, claimID)
case errors.Is(err, domains.ErrClaimNotReleasable):
data.Error = "That domain is part of our hosting and can't be released."
case errors.Is(err, domains.ErrClaimNotActive):
data.Error = "That domain isn't active — cancel its verification instead."
default:
h.Logger.Error("failed to release claim", slog.Any("error", err))
data.Error = "We couldn't release that domain. Please try again."
}
h.Templates.Render(w, "member_domains.html", data)
}
// placementGuardMessage explains a refused release by naming the names that
// still sit inside the claim, so "remove the sites first" is actionable
// rather than a riddle. The list is the member's own; nothing here can leak
// another workspace's names, since the release already proved ownership.
func (h *MemberDomainsHandler) placementGuardMessage(ctx context.Context, claimID string) string {
const base = "That domain still has sites on it. Remove them first, then release the domain."
if h.DomainsQ == nil {
return base
}
placements, err := h.DomainsQ.ListPlacementsByClaim(ctx, claimID)
if err != nil || len(placements) == 0 {
return base
}
const maxNamed = 3
names := make([]string, 0, maxNamed+1)
for i, placement := range placements {
if i == maxNamed {
names = append(names, "…")
break
}
names = append(names, placement.Fqdn)
}
return "That domain still serves " + strings.Join(names, ", ") +
". Remove those sites first, then release the domain."
}