Move sub-surfaces into their sections: billing views get a pill row, org types a header button. Replace inline IdP handoff copy with an SVG icon and tooltip, add help icons to dense form rows, and delete the registry-driven sidebar nav plumbing. Update specs and tests.
520 lines
20 KiB
Go
520 lines
20 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"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"
|
|
"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
|
|
}
|
|
|
|
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 is 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
|
|
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
|
|
}
|
|
|
|
// 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, false)
|
|
}
|
|
|
|
// OperatorLookup handles POST /operator/lookup — resolves the operator's
|
|
// typed lookup term to a person detail, an org composite, a disambiguation
|
|
// listing, or a no-match notice. Resolution order:
|
|
// 1. 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).
|
|
// 2. org slug exact; if found → HX-Redirect to org composite.
|
|
// 3. otherwise → render the result partial with a no-match notice.
|
|
//
|
|
// 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, 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, false)
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
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 org-slug 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, false)
|
|
return
|
|
}
|
|
|
|
if org, err := h.OrgQ.GetOrganizationBySlug(ctx, term); err == nil {
|
|
w.Header().Set("HX-Redirect", "/operator/organizations/"+org.OrgID)
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
h.renderLookupResult(w, term, nil, true)
|
|
}
|
|
|
|
// renderLookupResult renders the lookup-result partial for HTMX swap.
|
|
// `term` populates the no-match alert / disambiguation header; `candidates`
|
|
// drives the person picker; `noMatch` triggers the warning alert.
|
|
func (h *OperatorHandler) renderLookupResult(w http.ResponseWriter, term string, candidates []LookupCandidateViewModel, noMatch bool) {
|
|
data := struct {
|
|
LookupTerm string
|
|
LookupCandidates []LookupCandidateViewModel
|
|
LookupNoMatch bool
|
|
}{
|
|
LookupTerm: term,
|
|
LookupCandidates: candidates,
|
|
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` triggers the disambiguation
|
|
// list; `noMatch` triggers the no-match notice. All three are off on a
|
|
// plain landing render.
|
|
func (h *OperatorHandler) renderLanding(w http.ResponseWriter, r *http.Request, lookupTerm string, candidates []LookupCandidateViewModel, 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,
|
|
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).
|
|
}
|