Files
member-console/internal/server/operator_domains.go
T
cgalo5758 9b96e9c9e9 Rework operator IA and unify UI vocabulary
- Restructure operator sidebar into a flat task list with indented
  children; fold plan topology into plan ladders
- Expand member catalog non-plan section to all published non-tier
  products; require recurring Stripe-mapped prices for purchase
- Add operator domains placements and terminal-claims ledger; redirect
  /domains to the FedWiki Sites Domains anchor
- Apply canonical vocabulary and chrome/form conventions; migrate seeded
  FedWiki Sites display name
2026-08-23 17:12:42 -05:00

376 lines
15 KiB
Go

// 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
StatusClass 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
StatusClass 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
}
// domainClaimStatusClass maps a claim status to its badge class. Operators see
// the registry's own vocabulary — unlike members, whose surface translates it
// (member_domains.go's domainStatusLabel) — because the stored value is what
// they will match against logs and the database.
func domainClaimStatusClass(status string) string {
switch status {
case domains.StatusPending:
return "text-bg-warning"
case domains.StatusActive:
return "text-bg-success"
default:
return "text-bg-secondary"
}
}
// 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,
StatusClass: domainClaimStatusClass(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,
StatusClass: domainClaimStatusClass(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)
}