Files
member-console/internal/server/operator_domains.go
T
cgalo5758 12f1d3fc00 Fix the four Slice 3 walk findings and page every operator list
Archives openspec change slice3-walk-fixes and syncs its five delta
specs (fedwiki-sites, entitlements, operator-panel-navigation,
operator-list-scale, ui-quality-gate).

- FedWiki site usage is read from active site rows in both quota
  readers; the reservation counter converges on the rows: raise-only
  after farm sync and inside the create quota check, exact at boot.
  The understated production counters repair on the first boot.
- The People tile caption excludes the reserved system person through
  the same query parameter the directory uses.
- The operator Domains live-claims list is a governed list: pages of
  50, true total, search over root name and organization, a
  pending/active facet.
- New lint rule table-without-list-controls refuses an unpaged
  page-body table unless it carries a list-scale exempt marker with a
  reason; six curated or detail tables carry one. Its first run caught
  the operator FedWiki sites list, which is now governed the same way.
- Entitlement-set rule copy: "Per unit", "Multiplied by the quantity
  purchased or granted."
2026-09-12 01:16:17 -05:00

470 lines
19 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// 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 (
"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
// Nav governs the live-claims table only (operator-list-scale: search,
// the status facet, and true-total pagination). Placements and history
// stay unpaged on the same URL; if they ever page they take
// ParseListParamsNS prefixes so the three cannot collide (design D4).
Nav ListNav
Error string
}
// domainClaimFacets is the live claim vocabulary — exactly the two statuses
// the list can hold, so the filter offers neither a terminal status the
// query excludes nor a value the status machine does not have.
var domainClaimFacets = []FacetOption{
{Value: "pending", Label: "Pending"},
{Value: "active", Label: "Active"},
}
// 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.",
// The true total for the current search and filter, never the
// page's row count (operator-list-scale).
Count: countLabel(int(d.Nav.Total), "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 section with its count.
func (d OperatorDomainsData) HistoryHeader() SectionHeader {
return SectionHeader{Title: "Claim history", Count: countLabel(len(d.TerminalClaims), "terminal claim", "terminal claims")}
}
// 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.",
Note: "A placement is a site or service served at a claimed name.",
}
}
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 "moments ago"
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 one page of the deployment's live claims plus the
// effective policy, the placements, and the claim history. A read failure
// yields the banner rather than an empty table, so "no claims" never silently
// means "the query broke".
//
// The live-claims table is the governed list (operator-list-scale, design
// D4): its search, status facet, and page come off the request. The
// force-release re-render reads the same parameters from the POST it
// answers, which carries none, so a moderated claim leaves an unfiltered
// first page -- the list the operator is left looking at is current either
// way. The other three sections are unpaged and read the request not at all.
func (h *OperatorPartialsHandler) loadDomainsPageData(r *http.Request, errMsg string) OperatorDomainsData {
ctx := r.Context()
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),
}
params := ParseListParams(r, "status")
params.Facet = ValidFacet(params.Facet, domainClaimFacets)
rows, total, err := FetchPage(&params, func(limit, offset int32) ([]domains.ListLiveClaimsForOperatorPageRow, int64, error) {
return h.Registry.ListAllLiveClaimsPage(ctx, domains.OperatorClaimsPage{
Q: params.Q,
Status: params.Facet,
Limit: limit,
Offset: offset,
})
})
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.Nav = ListNav{
BasePath: "/operator/domains",
SearchPlaceholder: "Search by domain or organization",
FacetParam: "status",
FacetOptions: domainClaimFacets,
Q: params.Q,
Facet: params.Facet,
Page: params.Page,
Total: total,
}
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, "")
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, errMsg)
if status != http.StatusOK {
w.WriteHeader(status)
}
h.Templates.Render(w, "operator_domains.html", data)
}