// Operator Domains surface (claim-lifecycle-hardening design D7): the // deployment-wide view of who holds which name, plus the one moderation write // — force-release — an operator has against a claim they do not own. // // It exists because every other domains surface is workspace-scoped: a member // sees their own claims and nothing else, which is right for them and useless // for the operator answering "who is sitting on this name". The page also // prints the effective claim policy, since those keys are core flags that // appear on no integration settings page — this is the only place a // deployment's window, caps, and budgets are visible at all. package server import ( "context" "database/sql" "errors" "fmt" "log/slog" "net/http" "time" "git.coopcloud.tech/wiki-cafe/member-console/internal/domains" wfdomains "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/domains" ) // OperatorDomainClaimRow is one live claim on the moderation surface. type OperatorDomainClaimRow struct { ClaimID string Root string Kind string Status string OrgID string OrgName string // Age is how long the claim has held the name — the column that makes a // squatted name legible, which a creation timestamp does not. Age string Placements int64 // Evidence reports the zone-control latch: this holder published our // challenge value at some point, so their abandoning the name is never // counted against them automatically. Evidence bool // Releasable is false for operator roots, which the registry refuses to // release (they are ensured from configuration at every boot), AND for // claims that still hold placements, which the registry's placement // guard refuses identically (a name in use is freed by removing its // sites, not by moderation). The affordance is not offered where the // answer is always no — a rendered button the server will refuse is the // dead-end-affordance failure mode (issues.md, found 2026-07-26 when the // domains walkthrough hit it). Releasable bool // Placed marks the unreleasable-because-in-use case so the template can // label it honestly instead of calling everything "operator root". Placed bool } // OperatorDomainPolicyView is the effective claim policy, formatted for // display. Every field is what the registry actually allocates under, not // what any config file says: unset keys resolve to the package defaults, and // only the resolved set is true. type OperatorDomainPolicyView struct { ClaimWindow string PendingCap string AbandonBudget string AbandonWindow string ScopeLabels string InitiationBudget string } // OperatorPlacementRow is one row in the placements section: the exact name // actually being served, and the one provider resource it is wired to. // Claims and placements are never conflated // (docs/models/domains-registry.md, "Claim vs placement") -- a claim can // hold a name that serves nothing at all, so this list is deliberately // separate from the claims table above it: it answers "what actually // serves", not "who holds what". type OperatorPlacementRow struct { Name string Provider string ResourceRef string // Servable is kept in sync from the provider resource's own state (an // archived FedWiki site marks its placement unservable). An unservable // placement still occupies the name and still refuses the ask locally -- // the flag controls only the yes/no to the TLS proxy. Servable bool } // OperatorTerminalClaimRow is one claim that has left a live state (expired, // canceled, or released). Blame is never read from Status here -- "canceled" // says nothing about fault (dimension "Status vs blame") -- it is read from // the ledger columns and rendered as two derived fields: Evidence reports // whether evidence_at was ever stamped, and Outcome derives its text from // which of abandoned_at / system_canceled_at / evidence_at is set (see // domainTerminalOutcome). Outcome is blank for non-external claims: the // ledger governs external claims only. type OperatorTerminalClaimRow struct { ClaimID string Root string Kind string Status string OrgID string OrgName string Age string Evidence bool Outcome string } // OperatorDomainsData feeds operator_domains.html. type OperatorDomainsData struct { Claims []OperatorDomainClaimRow TerminalClaims []OperatorTerminalClaimRow Placements []OperatorPlacementRow Policy OperatorDomainPolicyView Error string } // Page anatomy: the page data and its rows build the shared parts' values // here, so the template never composes a title, a badge class, or an empty // state (docs/design-system.md §6). Statuses use the registry's own // vocabulary through the one badge map; operators match those words against // logs and the database. // Header is the page header with the live-claim count in its slot. func (d OperatorDomainsData) Header() PageHeader { return PageHeader{ Title: "Domains", Lead: "Every live claim in this deployment, and the names being served.", Count: countLabel(len(d.Claims), "live claim", "live claims"), } } // PolicyHeader titles the effective-claim-policy disclosure. func (OperatorDomainsData) PolicyHeader() SectionHeader { return SectionHeader{Title: "Effective claim policy"} } // PlacementsHeader titles the placements section with its count. func (d OperatorDomainsData) PlacementsHeader() SectionHeader { return SectionHeader{Title: "Placements", Count: countLabel(len(d.Placements), "served name", "served names")} } // HistoryHeader titles the claim-history disclosure; the count rides in // the summary so the closed disclosure still says how much it holds. func (d OperatorDomainsData) HistoryHeader() SectionHeader { return SectionHeader{Title: "Claim history", Count: fmt.Sprint(len(d.TerminalClaims))} } // NoClaims, NoPlacements, and NoHistory are the three empty states. func (OperatorDomainsData) NoClaims() EmptyStateParams { return EmptyStateParams{Headline: "No live domain claims."} } func (OperatorDomainsData) NoPlacements() EmptyStateParams { return EmptyStateParams{Headline: "No placements. Nothing is currently served."} } func (OperatorDomainsData) NoHistory() EmptyStateParams { return EmptyStateParams{Headline: "No expired, canceled, or released claims yet."} } // KindBadge, StatusBadge, and EvidenceBadge render a live claim's columns. func (r OperatorDomainClaimRow) KindBadge() Badge { return StatusBadge(r.Kind) } func (r OperatorDomainClaimRow) StatusBadge() Badge { return StatusBadge(r.Status) } // EvidenceBadge is the zone-control latch: this holder published our // challenge value, so the ledger never charges them for giving the name up. func (OperatorDomainClaimRow) EvidenceBadge() Badge { return StatusBadge("seen") } func (r OperatorTerminalClaimRow) KindBadge() Badge { return StatusBadge(r.Kind) } func (r OperatorTerminalClaimRow) StatusBadge() Badge { return StatusBadge(r.Status) } func (OperatorTerminalClaimRow) EvidenceBadge() Badge { return StatusBadge("seen") } // StateBadge renders servability, kept in sync from the provider resource's // own state; an unservable placement still occupies the name but answers // "no" to the TLS proxy. func (r OperatorPlacementRow) StateBadge() Badge { if r.Servable { return StatusBadge("servable") } return StatusBadge("unservable") } // claimAge renders how long a claim has held its name in the largest unit that // still says something. The question this column answers is "how long has this // been sitting here", which an absolute timestamp answers slowly. func claimAge(since time.Time) string { elapsed := time.Since(since) switch { case elapsed < time.Minute: return "just now" case elapsed < time.Hour: return fmt.Sprintf("%dm", int(elapsed.Minutes())) case elapsed < 24*time.Hour: return fmt.Sprintf("%dh", int(elapsed.Hours())) default: return fmt.Sprintf("%dd", int(elapsed.Hours()/24)) } } // formatPolicyWindow renders a policy duration in the unit it was configured // in. time.Duration's own String would print the 7-day abandonment window as // "168h0m0s", which is the same number and none of the meaning. func formatPolicyWindow(window time.Duration) string { switch { case window <= 0: return "—" case window%(24*time.Hour) == 0: return fmt.Sprintf("%dd", int(window/(24*time.Hour))) case window%time.Hour == 0: return fmt.Sprintf("%dh", int(window/time.Hour)) default: return window.String() } } // formatPolicyBudget renders a budget, naming a zero for what it is: that // check disabled, not a budget of nothing. func formatPolicyBudget(budget int) string { if budget <= 0 { return "disabled" } return fmt.Sprintf("%d", budget) } // domainTerminalOutcome renders a terminal claim's ledger truthfully: blame // lives in evidence_at / abandoned_at / system_canceled_at, never in status // (invariant 9, docs/models/domains-registry.md). The ledger governs // external claims only (dimension "Name policy vs abuse policy"), so a // carved member claim or a released operator_root claim gets no outcome // text. // // Order matters and is not arbitrary: // - system_canceled_at first: a console rollback never stamps // abandoned_at (design D4), so the two are mutually exclusive by // construction, but checking this one first keeps the code honest about // that guarantee rather than relying on it silently. // - abandoned_at next: MarkClaimCanceledForce stamps it unconditionally, // even over a claim that separately carries evidence_at, because an // operator taking the name back is itself the finding that the claim was // unwanted — a squatter who published a decoy record must not buy // immunity from moderation with it. // - evidence_at last: a claim that was ever verified, or that walked away // (member cancel, or timed out) after showing evidence, was never // charged. // - neither set is the fallback for a terminal external claim the ledger // never touched (release stamps nothing, and a claim canceled before any // probe ran carries no evidence either). func domainTerminalOutcome(kind string, evidenceAt, abandonedAt, systemCanceledAt sql.NullTime) string { if kind != domains.KindExternal { return "" } switch { case systemCanceledAt.Valid: return "rolled back by the system" case abandonedAt.Valid: return "abandoned" case evidenceAt.Valid: return "walked away with evidence published" default: return "—" } } // loadDomainsPageData reads every live claim in the deployment plus the // effective policy. A read failure yields the banner rather than an empty // table, so "no claims" never silently means "the query broke". func (h *OperatorPartialsHandler) loadDomainsPageData(ctx context.Context, errMsg string) OperatorDomainsData { data := OperatorDomainsData{Error: errMsg} if h.Registry == nil { data.Error = "The domain registry isn't available." return data } policy := h.Registry.Policy() data.Policy = OperatorDomainPolicyView{ ClaimWindow: formatPolicyWindow(policy.ClaimWindow), PendingCap: formatPolicyBudget(policy.PendingCap), AbandonBudget: formatPolicyBudget(policy.AbandonBudget), AbandonWindow: formatPolicyWindow(policy.AbandonWindow), ScopeLabels: fmt.Sprintf("%d", policy.ScopeLabels), InitiationBudget: formatPolicyBudget(policy.InitiationBudget), } rows, err := h.Registry.ListAllLiveClaims(ctx) if err != nil { h.Logger.Error("failed to list live domain claims", slog.Any("error", err)) data.Error = "Failed to load domain claims." return data } data.Claims = make([]OperatorDomainClaimRow, len(rows)) for i, row := range rows { data.Claims[i] = OperatorDomainClaimRow{ ClaimID: row.ClaimID, Root: row.RootFqdn, Kind: row.Kind, Status: row.Status, OrgID: row.OrgID, OrgName: row.OrgName, Age: claimAge(row.CreatedAt), Placements: row.PlacementCount, Evidence: row.EvidenceAt.Valid, Releasable: row.Kind != domains.KindOperatorRoot && row.PlacementCount == 0, Placed: row.Kind != domains.KindOperatorRoot && row.PlacementCount > 0, } } terminal, err := h.Registry.ListAllTerminalClaims(ctx) if err != nil { h.Logger.Error("failed to list terminal domain claims", slog.Any("error", err)) data.Error = "Failed to load domain claims." return data } data.TerminalClaims = make([]OperatorTerminalClaimRow, len(terminal)) for i, row := range terminal { data.TerminalClaims[i] = OperatorTerminalClaimRow{ ClaimID: row.ClaimID, Root: row.RootFqdn, Kind: row.Kind, Status: row.Status, OrgID: row.OrgID, OrgName: row.OrgName, Age: claimAge(row.CreatedAt), Evidence: row.EvidenceAt.Valid, Outcome: domainTerminalOutcome(row.Kind, row.EvidenceAt, row.AbandonedAt, row.SystemCanceledAt), } } placements, err := h.Registry.ListAllPlacements(ctx) if err != nil { h.Logger.Error("failed to list domain placements", slog.Any("error", err)) data.Error = "Failed to load domain placements." return data } data.Placements = make([]OperatorPlacementRow, len(placements)) for i, row := range placements { data.Placements[i] = OperatorPlacementRow{ Name: row.Fqdn, Provider: row.Provider, ResourceRef: row.ResourceRef, Servable: row.Servable, } } return data } // GetDomainsPage handles GET /operator/domains — live claims across every // workspace, newest-pending-first, with the effective claim policy. func (h *OperatorPartialsHandler) GetDomainsPage(w http.ResponseWriter, r *http.Request) { page := h.buildOperatorPageData(r) page.IAPosition = "runtime:domains" page.ActiveCapability = "domains" page.BodyTemplate = "operator_domains.html" page.BodyData = h.loadDomainsPageData(r.Context(), "") h.Templates.Render(w, "operator.html", page) } // ForceReleaseClaim handles POST /partials/operator/domains/{claimID}/force-release // — the moderation write. The registry frees the name under its own lock, with // the placement guard and the operator-root refusal members get; only ownership // is waived. The verification workflow is stopped afterwards, best-effort: the // claim is already terminal, and an orphaned poller writes nothing (every probe // write is pending-guarded) and finishes on its own. func (h *OperatorPartialsHandler) ForceReleaseClaim(w http.ResponseWriter, r *http.Request) { if h.Registry == nil { h.renderDomains(w, r, http.StatusServiceUnavailable, "", "The domain registry isn't available.") return } ctx := r.Context() claimID := r.PathValue("claimID") claim, err := h.Registry.ForceReleaseClaim(ctx, claimID) switch { case err == nil: case errors.Is(err, domains.ErrNotFound): h.renderDomains(w, r, http.StatusNotFound, "", "That claim no longer exists — the list below is current.") return case errors.Is(err, domains.ErrClaimNotReleasable): h.renderDomains(w, r, http.StatusConflict, "", "Operator roots come from deployment configuration and can't be released here.") return case errors.Is(err, domains.ErrClaimHasPlacements): h.renderDomains(w, r, http.StatusConflict, "", "That claim still has placements. Remove the sites on it first, then release it.") return case errors.Is(err, domains.ErrClaimNotActive): h.renderDomains(w, r, http.StatusConflict, "", "That claim reached a terminal state while the page was open — the list below is current.") return default: h.Logger.Error("failed to force-release domain claim", slog.String("claim_id", claimID), slog.Any("error", err)) h.renderDomains(w, r, http.StatusInternalServerError, "", "Failed to release that claim. Details are in the server logs.") return } if claim.Status == domains.StatusPending && h.TemporalClient != nil { if cErr := h.TemporalClient.CancelWorkflow(ctx, wfdomains.VerifyClaimWorkflowIDPrefix+claimID, ""); cErr != nil { h.Logger.Warn("failed to cancel verification workflow after force-release", slog.String("claim_id", claimID), slog.Any("error", cErr)) } } h.Logger.Info("operator force-released a domain claim", slog.String("claim_id", claimID), slog.String("root", claim.RootFqdn), slog.String("was_status", claim.Status), slog.String("workspace_id", claim.WorkspaceID)) h.renderDomains(w, r, http.StatusOK, claim.RootFqdn+" released", "") } // renderDomains re-renders the claims list into #operator-body at the given // status: 200 with a toast on success, a 4xx/5xx with the banner otherwise // (docs/operator-ux-conventions.md §8 — the status is what error-handler.js // keys off, and a refused mutation must never answer 200). func (h *OperatorPartialsHandler) renderDomains(w http.ResponseWriter, r *http.Request, status int, success, errMsg string) { fireSuccessToast(w, success) data := h.loadDomainsPageData(r.Context(), errMsg) if status != http.StatusOK { w.WriteHeader(status) } h.Templates.Render(w, "operator_domains.html", data) }