- 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
376 lines
15 KiB
Go
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)
|
|
}
|