Files
member-console/internal/server/operator.go
T
cgalo5758 408fa6f5a6 Add page anatomy parts and UI quality gate
- Add shared ui_*.html parts (pageHeader, sectionHeader, statusBadge,
  emptyState) parsed into every template set
- Add anatomy lint rules with a shrinking allowlist and screen-coverage
  check
- Add make screens capture harness with contact sheets and baseline diff
- Compose member and FedWiki regions server-side so pages arrive
  complete
- Rebuild Domains and Integrations on the parts as pilots
2026-08-30 04:05:31 -05:00

654 lines
25 KiB
Go

package server
import (
"bytes"
"context"
"database/sql"
"errors"
"html/template"
"io/fs"
"log/slog"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"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/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"github.com/spf13/viper"
)
// OperatorHandler handles the main operator page
type OperatorHandler struct {
AuthConfig *auth.Config
Logger *slog.Logger
Templates *SafeTemplates
Database *sql.DB // for the registry-driven Integration nav on the landing surface
BillingQ billing.Querier
EntitlementsQ entitlements.Querier
OrgQ organization.Querier
IdentityQ identity.Querier
// IntegrationQ backs the landing surface's System panel (registered
// providers + outbox health). Derived from Database in the constructor;
// a separate field so tests can inject a fake without a live database.
IntegrationQ integration.Querier
// IntegrationConfigs backs the System panel's configuration-readiness
// signal (configurationReadiness) — the same declared-config list the
// Integrations list and the per-integration settings page read.
IntegrationConfigs []IntegrationConfigInfo
// StripeQ backs the setup checklist's Stripe-price-mapping predicate
// (AnyMappedPrice), composed with BillingQ.AnyActivePrice to derive the
// conditional "sync a price to Stripe" step (setup_state.go). Derived
// from Database in the constructor, matching IntegrationQ.
StripeQ stripedb.Querier
}
// OperatorHandlerConfig holds configuration for the operator handler
type OperatorHandlerConfig struct {
AuthConfig *auth.Config
Logger *slog.Logger
Database *sql.DB
BillingQ billing.Querier
EntitlementsQ entitlements.Querier
OrgQ organization.Querier
IdentityQ identity.Querier
IntegrationConfigs []IntegrationConfigInfo
}
// NewOperatorHandler creates a new OperatorHandler
func NewOperatorHandler(cfg OperatorHandlerConfig) (*OperatorHandler, error) {
// Parse operator template
templateSubFS, err := fs.Sub(embeds.Templates, "templates")
if err != nil {
return nil, err
}
// operator.html references the `renderBody` template func used by the
// MPA page handlers' BodyTemplate dispatch. This handler now uses it too
// (GetSetupPage, operator_setup.go), so — unlike the former no-op stub —
// it must actually dispatch: look up the named template inside this same
// set and render it. The closure captures `tmpl` (assigned below); funcs
// must be installed before parsing so the parser accepts references to
// "renderBody", so we pre-construct the empty template, install the
// func, then ParseFS. Mirrors NewOperatorPartialsHandler's renderBody.
// Also parses the lookup-result partial — operator.html `{{ template }}`s
// it inline, and the OperatorLookup handler renders it standalone for
// HTMX swap responses — and the setup-checklist partial, shared by the
// landing region and GetSetupPage's BodyTemplate dispatch.
var tmpl *template.Template
tmpl = template.New("operator").Funcs(template.FuncMap{
"renderBody": func(name string, data any) (template.HTML, error) {
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
}
return template.HTML(buf.String()), nil
},
"deploymentName": config.DeploymentName,
})
tmpl, err = tmpl.ParseFS(templateSubFS, "operator.html", "partials/operator_lookup_result.html", "partials/operator_setup.html")
if err != nil {
return nil, err
}
if tmpl, err = web.ParseUIPartials(tmpl); err != nil {
return nil, err
}
return &OperatorHandler{
AuthConfig: cfg.AuthConfig,
Logger: cfg.Logger,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
Database: cfg.Database,
BillingQ: cfg.BillingQ,
EntitlementsQ: cfg.EntitlementsQ,
OrgQ: cfg.OrgQ,
IdentityQ: cfg.IdentityQ,
IntegrationQ: integration.New(cfg.Database),
IntegrationConfigs: cfg.IntegrationConfigs,
StripeQ: stripedb.New(cfg.Database),
}, nil
}
// RegisterRoutes registers operator routes
func (h *OperatorHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /operator", h.requireOperatorRole(h.GetOperatorPage))
mux.HandleFunc("POST /operator/lookup", h.requireOperatorRole(h.OperatorLookup))
mux.HandleFunc("GET /operator/setup", h.requireOperatorRole(h.GetSetupPage))
}
// requireOperatorRole is middleware that checks for the operator role
func (h *OperatorHandler) requireOperatorRole(next http.HandlerFunc) http.HandlerFunc {
return RequireOperatorRole(h.AuthConfig, h.Logger, next)
}
// RequireOperatorRole wraps next so it only runs for a request whose session
// holds the operator-member role; otherwise it responds 403 and logs the
// denial. Exported (beyond the OperatorHandler/OperatorPartialsHandler
// methods that delegate to it below) so an integration registering its own
// operator surface — design.md Decision 8; e.g.
// internal/integrations/fedwiki/web's read-only sites page — gates it with
// the same check core's operator pages use, instead of duplicating the
// role check inline.
func RequireOperatorRole(authConfig *auth.Config, logger *slog.Logger, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !authConfig.HasRole(r, OperatorRole) {
logger.Warn("operator access denied",
slog.String("path", r.URL.Path),
slog.String("reason", "missing operator-member role"))
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next(w, r)
}
}
// OperatorPageData holds data for the operator page template.
type OperatorPageData struct {
Name string
Username string
Email string
KeycloakAccountURL string
CSRFToken string
// IAPosition is the positional tuple `<group>:<capability>[:<instance>]`
// declared by every operator page per the breadcrumb requirement. The
// landing surface declares `runtime:landing` since it sits in the
// runtime group as the curated hot-path entry point.
IAPosition string
// ActiveCapability is the sidebar-link key the current page belongs to
// ("organizations", "grants", ...). Drives the sidebar `active` class.
// Empty on the landing surface (Overview light up).
ActiveCapability string
// BodyTemplate names the partial template to render inside <main>. When
// non-empty the operator.html shell dispatches via renderBody; when
// empty it renders the curated landing surface.
BodyTemplate string
BodyData any
// Activity is the unified-timeline rows for the landing surface's
// "Recent activity" section (operator-composite-expansion D4).
// Populated only on the landing branch; nil/empty on MPA pages.
Activity []ActivityEvent
// Overview holds the landing surface's "At a glance" counts and "System"
// signals. Populated only on the landing branch; the zero value renders
// the surface's empty states, which is what MPA pages get.
Overview OverviewData
// Setup is the setup checklist's derived state, shared by the landing
// region (rendered here, ahead of Overview's tiles, only while
// Setup.RequiredIncomplete) and GetSetupPage's full-page rendering
// (setup_state.go, operator_setup.go). Populated only on the landing
// branch; the zero value's RequiredIncomplete is false, so MPA pages
// render no setup region (they have none to render).
Setup SetupState
// Lookup state for the landing-surface lookup affordance. LookupTerm
// retains the operator's input on re-render so they can edit and
// re-submit; LookupCandidates / LookupOrgCandidates are populated on
// ambiguous matches; LookupNoMatch is set on zero-match. Unique
// matches don't end up here — they 303-redirect to the resolved
// detail page.
LookupTerm string
LookupCandidates []LookupCandidateViewModel
LookupOrgCandidates []LookupOrgCandidateViewModel
LookupNoMatch bool
}
// LookupCandidateViewModel represents one candidate person in the
// disambiguation list rendered when a lookup term matches multiple
// persons by name substring.
type LookupCandidateViewModel struct {
PersonID string
DisplayName string
Email string
}
// LookupOrgCandidateViewModel represents one candidate organization in the
// disambiguation list rendered when a lookup term matches no person but
// several organizations by name substring (design D2).
type LookupOrgCandidateViewModel struct {
OrgID string
Name string
OrgType string
OwnerName string
}
// ActivityEvent is one row in the landing-surface unified timeline. Each
// row identifies its event type, the org (and person, when applicable),
// a short human summary, and a precomputed link target. Per D4 the
// template branches on EventType to render the right badge/icon — the
// loader doesn't pick visual styles, it picks data.
type ActivityEvent struct {
EventType string // "grant_issued" | "transition" | "invoice_created" | "payment_received"
Timestamp string // pre-formatted display string ("Jan 2, 2006 3:04 PM")
SortKey int64 // unix nanos, used to sort the merged slice
OrgID string
OrgName string
PersonID string // empty when no person attribution (system/webhook actor or billing event)
PersonName string
Summary string // human-readable description of what happened
// Href is the entry's own addressable operator surface (D8/task 7.4:
// "feed entries link to their subjects where addressable"): the org
// detail page for a grant or transition, the billing view for an
// invoice or payment. Empty when the entry has no addressable surface —
// the template renders those entries as plain text, never a dead link.
Href string
}
// GetOperatorPage handles GET /operator
func (h *OperatorHandler) GetOperatorPage(w http.ResponseWriter, r *http.Request) {
// Permanent redirect for legacy ?tab=<id> bookmarks. Slated for
// removal in M8 once operator analytics show no remaining hits.
if tab := r.URL.Query().Get("tab"); tab != "" {
if redirect, ok := legacyTabRedirects[tab]; ok {
http.Redirect(w, r, redirect, http.StatusMovedPermanently)
return
}
}
h.renderLanding(w, r, "", nil, nil, false)
}
// lookupOrgSearchLimit bounds the organization-name disambiguation list
// (design D2): a short substring term can otherwise match a large slice of
// the table.
const lookupOrgSearchLimit = 20
// OperatorLookup handles POST /operator/lookup — resolves the operator's
// typed lookup term to a person detail, an org composite, a catalog detail
// page, a disambiguation listing, or a no-match notice. Resolution order:
// 1. exact key match, when the term is shaped like a key at all
// (entity-keys §5: "operator lookup matches keys exactly, after IDs and
// before names"). Keys are exact and unique, so a match is never
// ambiguous; there is no ID branch in this handler yet, so this is the
// first step.
// 2. email-exact + name-substring against active persons (one query); if
// one match → HX-Redirect to person detail; if many → render the
// result partial with the disambiguation list (HTMX swap).
// 3. only when no person matched: name-substring against active
// organizations (SearchOrganizationsByName, bounded limit); one match
// → HX-Redirect to the org composite; several → render the result
// partial with organization candidates (name, type, owner, each
// linking to its composite).
// 4. otherwise → render the result partial with a no-match notice.
//
// Persons resolve before organization names because every personal
// organization's name contains its owner's display name, so a bare name
// match must land on the person (design D2). Keys resolve before both: a key
// is what a seed, a configuration file, or a script names a row by, so an
// operator typing one is naming that row and not describing it.
//
// HTMX (hx-post on the form) handles the X-CSRF-Token header via the
// body's hx-headers; this is the same CSRF path every other operator
// form uses. Native form POST hit a brittle Origin-check path in
// gorilla CSRF for some browsers (origin=null on certain submissions),
// so we route through HTMX instead.
func (h *OperatorHandler) OperatorLookup(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderLookupResult(w, "", nil, nil, true)
return
}
term := strings.TrimSpace(r.FormValue("term"))
if term == "" {
// Empty submit: nothing to render. Return empty body so HTMX clears
// any previous result without showing a state.
h.renderLookupResult(w, "", nil, nil, false)
return
}
ctx := r.Context()
if path := h.lookupByKey(ctx, term); path != "" {
w.Header().Set("HX-Redirect", path)
w.WriteHeader(http.StatusOK)
return
}
persons, err := h.IdentityQ.LookupPersons(ctx, term)
if err != nil {
h.Logger.Warn("lookup: persons query failed", slog.Any("error", err))
}
switch len(persons) {
case 1:
w.Header().Set("HX-Redirect", "/operator/persons/"+persons[0].PersonID)
w.WriteHeader(http.StatusOK)
return
case 0:
// Fall through to the organization-name check.
default:
candidates := make([]LookupCandidateViewModel, len(persons))
for i, p := range persons {
candidates[i] = LookupCandidateViewModel{
PersonID: p.PersonID,
DisplayName: p.DisplayName,
Email: p.PrimaryEmail,
}
}
h.renderLookupResult(w, term, candidates, nil, false)
return
}
orgs, err := h.OrgQ.SearchOrganizationsByName(ctx, organization.SearchOrganizationsByNameParams{
Name: term,
RowLimit: lookupOrgSearchLimit,
})
if err != nil {
h.Logger.Warn("lookup: organizations query failed", slog.Any("error", err))
}
switch len(orgs) {
case 1:
w.Header().Set("HX-Redirect", "/operator/organizations/"+orgs[0].OrgID)
w.WriteHeader(http.StatusOK)
return
case 0:
// Fall through to no-match.
default:
orgCandidates := make([]LookupOrgCandidateViewModel, len(orgs))
for i, o := range orgs {
orgCandidates[i] = LookupOrgCandidateViewModel{
OrgID: o.OrgID,
Name: o.Name,
OrgType: o.OrgType,
OwnerName: o.OwnerDisplayName.String,
}
}
h.renderLookupResult(w, term, nil, orgCandidates, false)
return
}
h.renderLookupResult(w, term, nil, nil, true)
}
// entityKeyGrammar is the ratified key grammar (entity-keys §4), the same
// pattern every `chk_*_key_grammar` CHECK constraint carries. A term that
// cannot be a key is not looked up as one, so an ordinary name search costs
// no extra queries.
var entityKeyGrammar = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
// entityKeyMaxLength is the length half of the same contract.
const entityKeyMaxLength = 64
// lookupByKey resolves an exact key match across the four keyed entities an
// operator can reach a detail page for, and returns the path to redirect to
// (empty when nothing matches). A key is unique within its scope and all four
// of these are root-scoped, so at most one row per entity can match; the
// entities are searched in a fixed order and the first hit wins. A NULL key
// never matches, because the comparison is `=`.
//
// Every query failure other than "no rows" is logged and treated as no match:
// lookup degrades to the name search rather than showing the operator an
// error for a term that was probably a name anyway. A nil querier (a handler
// built without the catalog queries, as some tests do) skips its entities.
func (h *OperatorHandler) lookupByKey(ctx context.Context, term string) string {
if len(term) > entityKeyMaxLength || !entityKeyGrammar.MatchString(term) {
return ""
}
key := sql.NullString{String: term, Valid: true}
noteErr := func(what string, err error) bool {
if err == nil {
return false
}
if !errors.Is(err, sql.ErrNoRows) {
h.Logger.Warn("lookup: key query failed", slog.String("entity", what), slog.Any("error", err))
}
return true
}
if h.OrgQ != nil {
org, err := h.OrgQ.GetOrganizationByKey(ctx, key)
if !noteErr("organization", err) {
return "/operator/organizations/" + org.OrgID
}
}
if h.BillingQ != nil {
ladder, err := h.BillingQ.GetPlanLadderByKey(ctx, key)
if !noteErr("plan ladder", err) {
return "/operator/plan-ladders/" + ladder.PlanLadderID
}
}
if h.EntitlementsQ != nil {
set, err := h.EntitlementsQ.GetEntitlementSetByKey(ctx, key)
if !noteErr("entitlement set", err) {
return "/operator/entitlement-sets/" + set.SetID
}
}
if h.BillingQ != nil {
product, err := h.BillingQ.GetProductByKey(ctx, key)
if !noteErr("product", err) {
return "/operator/products/" + product.ProductID
}
}
return ""
}
// renderLookupResult renders the lookup-result partial for HTMX swap.
// `term` populates the no-match alert / disambiguation header;
// `candidates` drives the person picker; `orgCandidates` drives the
// organization picker (mutually exclusive with `candidates` — persons
// resolve first); `noMatch` triggers the warning alert.
func (h *OperatorHandler) renderLookupResult(w http.ResponseWriter, term string, candidates []LookupCandidateViewModel, orgCandidates []LookupOrgCandidateViewModel, noMatch bool) {
data := struct {
LookupTerm string
LookupCandidates []LookupCandidateViewModel
LookupOrgCandidates []LookupOrgCandidateViewModel
LookupNoMatch bool
}{
LookupTerm: term,
LookupCandidates: candidates,
LookupOrgCandidates: orgCandidates,
LookupNoMatch: noMatch,
}
h.Templates.Render(w, "operator_lookup_result.html", data)
}
// renderLanding renders the operator landing surface with optional lookup
// state. `lookupTerm` populates the form input on re-render so the
// operator can edit and resubmit; `candidates` / `orgCandidates` trigger
// the disambiguation list; `noMatch` triggers the no-match notice. All are
// off on a plain landing render.
func (h *OperatorHandler) renderLanding(w http.ResponseWriter, r *http.Request, lookupTerm string, candidates []LookupCandidateViewModel, orgCandidates []LookupOrgCandidateViewModel, noMatch bool) {
ctx := r.Context()
limit := parseActivityLimit(r.URL.Query().Get("limit"))
data := OperatorPageData{
Name: h.AuthConfig.GetUserName(ctx),
Username: h.AuthConfig.GetUsername(ctx),
Email: h.AuthConfig.GetUserEmail(ctx),
KeycloakAccountURL: viper.GetString("oidc-idp-issuer-url") + "/account",
CSRFToken: middleware.CSRFToken(r),
IAPosition: "runtime:landing",
Overview: h.loadOverview(ctx),
Setup: h.DeriveSetupState(ctx),
Activity: h.loadRecentActivity(ctx, limit),
LookupTerm: lookupTerm,
LookupCandidates: candidates,
LookupOrgCandidates: orgCandidates,
LookupNoMatch: noMatch,
}
h.Templates.Render(w, "operator.html", data)
}
// parseActivityLimit clamps the ?limit=N param to [1, 100] with a default
// of 20. Per D4 the activity feed is one unified timeline and one knob —
// no separate ?activity_limit / ?billing_limit splits.
func parseActivityLimit(raw string) int32 {
const defaultLimit, maxLimit = 20, 100
if raw == "" {
return defaultLimit
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return defaultLimit
}
if n > maxLimit {
return maxLimit
}
return int32(n)
}
// loadRecentActivity assembles the landing-surface unified timeline by
// fetching `limit` rows from each event source (grants, transitions,
// invoices, payments), resolving org and person UUIDs to display names,
// then merging by timestamp DESC and trimming back to `limit`. Per-source
// over-fetch + post-merge trim is correct because no single source's
// recent N rows are guaranteed to be the global recent N.
//
// Failures from any individual source are logged and the source is
// dropped — the timeline degrades gracefully rather than 500ing the
// landing surface. An empty result is the legitimate empty state.
func (h *OperatorHandler) loadRecentActivity(ctx context.Context, limit int32) []ActivityEvent {
events := make([]ActivityEvent, 0, int(limit)*4)
if grants, err := h.EntitlementsQ.ListRecentGrants(ctx, limit); err == nil {
for _, g := range grants {
orgID := ""
if g.GrantedToOrgID.Valid {
orgID = g.GrantedToOrgID.UUID.String()
}
personID := ""
if g.GrantedByPersonID.Valid {
personID = g.GrantedByPersonID.UUID.String()
}
productName := ""
if p, err := h.BillingQ.GetProductByID(ctx, g.ProductID); err == nil {
productName = p.Name
}
events = append(events, ActivityEvent{
EventType: "grant_issued",
Timestamp: g.CreatedAt.Format("Jan 2, 2006 3:04 PM"),
SortKey: g.CreatedAt.UnixNano(),
OrgID: orgID,
PersonID: personID,
Summary: "Issued " + productName + " (" + g.GrantReason + ")",
Href: "/operator/organizations/" + orgID,
})
}
} else {
h.Logger.Warn("activity feed: list recent grants failed", slog.Any("error", err))
}
if transitions, err := h.EntitlementsQ.ListRecentTransitions(ctx, limit); err == nil {
for _, t := range transitions {
personID := ""
if t.ActorID.Valid {
personID = t.ActorID.UUID.String()
}
from := "—"
if t.FromRank.Valid {
from = strconv.Itoa(int(t.FromRank.Int32))
}
to := "—"
if t.ToRank.Valid {
to = strconv.Itoa(int(t.ToRank.Int32))
}
events = append(events, ActivityEvent{
EventType: "transition",
Timestamp: t.EffectiveAt.Format("Jan 2, 2006 3:04 PM"),
SortKey: t.EffectiveAt.UnixNano(),
OrgID: t.OrgID,
PersonID: personID,
Summary: t.TransitionType + " (rank " + from + " → " + to + ", by " + t.ActorType + ")",
Href: "/operator/organizations/" + t.OrgID,
})
}
} else {
h.Logger.Warn("activity feed: list recent transitions failed", slog.Any("error", err))
}
if invoices, err := h.BillingQ.ListRecentInvoices(ctx, limit); err == nil {
for _, inv := range invoices {
events = append(events, ActivityEvent{
EventType: "invoice_created",
Timestamp: inv.CreatedAt.Format("Jan 2, 2006 3:04 PM"),
SortKey: inv.CreatedAt.UnixNano(),
OrgID: inv.OrgID,
Summary: "Invoice " + formatCurrency(inv.AmountDue, inv.Currency) + " · " + inv.Status,
Href: "/operator/billing/invoices",
})
}
} else {
h.Logger.Warn("activity feed: list recent invoices failed", slog.Any("error", err))
}
if payments, err := h.BillingQ.ListRecentPayments(ctx, limit); err == nil {
for _, p := range payments {
events = append(events, ActivityEvent{
EventType: "payment_received",
Timestamp: p.CreatedAt.Format("Jan 2, 2006 3:04 PM"),
SortKey: p.CreatedAt.UnixNano(),
OrgID: p.OrgID,
Summary: "Payment " + formatCurrency(p.Amount, p.Currency) + " · " + p.Status,
Href: "/operator/billing/payments",
})
}
} else {
h.Logger.Warn("activity feed: list recent payments failed", slog.Any("error", err))
}
// Merge by timestamp DESC, then trim to limit.
sort.Slice(events, func(i, j int) bool { return events[i].SortKey > events[j].SortKey })
if int32(len(events)) > limit {
events = events[:limit]
}
// Resolve org and person display names in a second pass. Per-row
// lookups would be N+1 — collect distinct IDs first, then map.
orgNames := make(map[string]string)
personNames := make(map[string]string)
for _, e := range events {
if e.OrgID != "" {
orgNames[e.OrgID] = ""
}
if e.PersonID != "" {
personNames[e.PersonID] = ""
}
}
for orgID := range orgNames {
if o, err := h.OrgQ.GetOrganizationByID(ctx, orgID); err == nil {
orgNames[orgID] = o.Name
}
}
for personID := range personNames {
if p, err := h.IdentityQ.GetPersonByID(ctx, personID); err == nil {
personNames[personID] = p.DisplayName
}
}
for i := range events {
events[i].OrgName = orgNames[events[i].OrgID]
events[i].PersonName = personNames[events[i].PersonID]
}
return events
}
// legacyTabRedirects maps retired ?tab=<id> bookmarks to their replacement
// MPA route. Scheduled for removal in M8 — see openspec change
// operator-mpa-conversion task 6.2.
var legacyTabRedirects = map[string]string{
"orgs": "/operator/organizations",
"organizations": "/operator/organizations",
"grants": "/operator/grants",
"billing": "/operator/billing/accounts",
"org-types": "/operator/org-types",
"products": "/operator/products",
"entitlement-sets": "/operator/entitlement-sets",
"plan-ladders": "/operator/plan-ladders",
// Persons has no browse route per design D4. tab=people bookmarks
// land on the operator landing surface, where the lookup affordance
// will route them to the per-person detail page.
"people": "/operator",
// "sites" (the legacy FedWiki sites tab) is intentionally absent: its
// destination is now an integration-registered route (design.md
// Decision 8; see internal/integrations/fedwiki/web), and core must
// not hardcode an integration-specific path here to redirect to it
// (openspec/changes/integration-extraction task 2.7). A ?tab=sites
// bookmark now falls through to the landing surface below rather than
// resolving to a specific redirect — acceptable since this whole map
// is already slated for removal (operator-mpa-conversion task 6.2).
}